feat: 配置版本历史/对比/回滚、国内 IM 通知渠道、阈值告警与 API Token(v1.2.9)
- 配置版本:fsutil 版本化备份(内容相同自动去重),主 nginx.conf 与 VPS vhost 的版本列表/逐行差异/一键回滚,回滚先校验语法与 nginx -t 再写回 - agent 0.12.2:写 vhost 前留版本;nginx -t 校验失败改为恢复上一个可用版本(此前直接删文件),新增域名 vhost 版本端点,双架构二进制已重编 - 通知:补企业微信 / 钉钉 / 飞书机器人,切换预设时服务地址跟随该预设默认值 - 阈值告警:新增 internal/monitor 巡检(CPU/内存/磁盘持续超阈值、隧道连续 N 次未注册),默认关闭,带静默期与恢复通知 - API Token:api_tokens 表 + Authorization Bearer 认证,只读/读写两种范围;令牌管理与改密/退出仅限登录会话
This commit is contained in:
@@ -2,6 +2,42 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.2.9] - 2026-09-24
|
||||
|
||||
### 新增
|
||||
|
||||
- **配置版本历史 / 对比 / 回滚**:主 `nginx.conf` 每次保存前留一份版本(`<path>.versions/`,保留最近 10 次**内容有变化**的改动,内容相同自动去重);设置页「Nginx 全局配置」内可看版本列表、逐行差异并一键回滚——回滚先过语法校验与 `nginx -t`,不过关不覆盖当前配置
|
||||
- 公网反代页的「查看配置」弹窗内可看该域名在 VPS 上的历史版本、与当前配置做差异对比并回滚(需 agent ≥ 0.12.2)
|
||||
- **通知渠道补齐**:新增企业微信机器人、钉钉机器人、飞书机器人(钉钉正文以「Fonu · 」开头,机器人用「自定义关键词 = Fonu」即可命中;加签方式暂不支持)
|
||||
- **阈值型告警**(默认关闭):CPU / 内存 / 磁盘持续超阈值、隧道连续 N 次未在 frps 上注册时通知,恢复时再通知一次;阈值与开关在设置页「告警事件」里配置
|
||||
- **API Token**:新增 `api_tokens` 表与 `Authorization: Bearer <token>` 认证,供脚本 / HomeAssistant / 手机快捷指令调用;设置页「高级」可签发(明文只显示一次)、查看最近使用时间与撤销
|
||||
|
||||
### 修复
|
||||
|
||||
- agent 写 vhost 时 `nginx -t` 校验失败**不再直接删文件**(那等于把在跑的站点从磁盘上抹掉),改为恢复上一个可用版本
|
||||
- 配置版本名做路径校验,拒绝带 `/` 或 `..` 的名称,避免接口被用来读版本目录以外的文件
|
||||
- 通知:切换 Webhook 预设时服务地址自动换成该预设默认值(此前会把 Bark 的 `api.day.app` 带到企业微信上)
|
||||
- API Token 的令牌管理、改密、退出**只允许登录会话**——否则一个 write 令牌能给自己提权或无限续期
|
||||
|
||||
### 改进
|
||||
|
||||
- `internal/fsutil` 新增 `BackupVersioned` / `ListVersions` / `ReadVersion`;`internal/nginx` 的回滚与 `Apply` 共用抽出的 `activate()`,两条路径行为一致
|
||||
- 新增 `internal/monitor` 巡检包(每 2 分钟一轮,注册进 `internal/scheduler`);资源类告警同一项 30 分钟静默期,回落到阈值以下即清冷却,开关关闭时连采集与探测都跳过
|
||||
- `providerUsesKey()` 收敛「凭据放 Key 还是 WebhookSecret」的判定;前端新增 `ConfigDiffView.vue` 与 `utils/diff.ts`(LCS 行级差异,超长文本退化为整体替换)
|
||||
- API Token 的 `last_used_at` 限频回写(1 分钟一次),单连接 SQLite 上不做每请求写
|
||||
|
||||
### 测试
|
||||
|
||||
- `internal/auth/token_test.go`(令牌生命周期 / 过期 / 非法输入 / 明文不落盘)、`internal/api/middleware_test.go`(只读令牌只读、写令牌可写、令牌不能管理令牌、错误凭据 401)
|
||||
- `internal/monitor/monitor_test.go`(连续失败只报一次、恢复报一次、未达阈值静默)
|
||||
- `internal/notify/push_test.go` 增加企业微信 / 钉钉 / 飞书三家的端点与 payload 用例;`internal/fsutil` 增加版本列表 / 内容去重 / 路径校验用例
|
||||
|
||||
### 变更
|
||||
|
||||
- `AgentVersion` 0.12.1 → **0.12.2**(域名 vhost 的版本列表 / 对比 / 回滚端点,写配置前留版本);内嵌 `fonu-agent-linux-amd64` 与 `-arm64` 已重编,VPS 上需点「升级 Agent」才会生效
|
||||
- 版本号提升到 1.2.9(版本单一来源仍是根目录 `VERSION`);`web/package.json` 同步对齐
|
||||
- 新增 `docs/feature-benchmark-2026-09-24.md`:同类项目对标与增补建议,含 §九 本轮 P0 实施状态
|
||||
|
||||
## [v1.2.8] - 2026-09-24
|
||||
|
||||
### 修复
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
# 同类项目对标与增补建议
|
||||
|
||||
> 日期:2026-09-24 对象:Fonu 1.0.2(VERSION 1.2.9)
|
||||
> 方法:检索 GitHub 上与本项目同域的开源项目(隧道/反代管理面板、自托管监控、Nginx 面板、DDNS 面板),逐项对照 Fonu 现状给出「可优化 / 可增补」。
|
||||
> 可信度:对标项目的能力来自其首页 / README 摘要与检索片段,**未逐个深读源码**;Fonu 现状来自本仓库代码与 2026-09-24 的全项目审查(`docs/project-audit-2026-09-24.md`)。
|
||||
|
||||
---
|
||||
|
||||
## 一、对标项目
|
||||
|
||||
| 项目 | 定位 | 与 Fonu 的关系 |
|
||||
| --- | --- | --- |
|
||||
| [fosrl/pangolin](https://github.com/fosrl/pangolin) | 自托管**隧道式反代管理服务**,内置身份与访问控制 + 仪表盘 UI | 架构最接近:VPS 侧服务 + 内网侧隧道客户端 + 统一面板;差别在它主打**身份/访问控制**(SSO、资源级策略) |
|
||||
| [vaala/frp-panel](https://github.com/vaala/frp-panel) | frp 的**中心化控制面板**(Go + gRPC + protobuf,React/Next + shadcn) | 同为「面板 + 多客户端」模型;它管的是多台内网 frpc,Fonu 管的是本机 frpc + 多台公网 frps |
|
||||
| [henrygd/beszel](https://github.com/henrygd/beszel) | 轻量服务器监控,**hub + agent**,含历史数据、Docker 统计、告警、多用户、OAuth、API | 与 Fonu 的 app + fonu-agent 架构同型;它在「历史/告警/账号」上明显更成熟 |
|
||||
| [0xJacky/nginx-ui](https://github.com/0xJacky/nginx-ui) | Nginx 的 Web 管理界面:配置**版本备份/对比/回滚**、集群、证书一键签发与续期、在线编辑、加密配置导出 | Fonu 的反代层与它重叠;它的配置版本管理值得直接借鉴 |
|
||||
| [jeessy2/ddns-go](https://github.com/jeessy2/ddns-go) / [qdm12/ddns-updater](https://github.com/qdm12/ddns-updater) | DDNS 客户端:多服务商、Web UI、多记录类型、通知(ddns-updater 走 Shoutrrr,支持几十种渠道) | Fonu 的 DDNS 模块与其重叠;差距在**通知渠道数量**与记录类型覆盖 |
|
||||
|
||||
---
|
||||
|
||||
## 二、能力对照(✔ 已有 / ◐ 部分 / ✘ 无)
|
||||
|
||||
| 能力 | pangolin | frp-panel | beszel | nginx-ui | ddns-go | Fonu 现状 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| 面板 + 内网 agent(SSH 引导安装) | ✔ | ✔ | ✔ | ◐ | ✘ | **✔**(SSH 装 agent、HTTP 管理、TOFU 主机密钥) |
|
||||
| 多台公网服务端 + 多规则类型 | ✘ | ◐ | ✘ | ✘ | ✘ | **✔**(frps 多台、8 种穿透类型、规则按服务端归属) |
|
||||
| 公网反代下发 + 证书推送 | ✔ | ◐ | ✘ | ✔ | ✘ | **✔**(vhost 模板生成、证书推送、部署漂移检测、四段健康检查) |
|
||||
| 配置**版本历史 / 对比 / 回滚** | ◐ | ◐ | ✘ | **✔** | ✘ | **✘**(只有单份 `.bak`) |
|
||||
| 指标**历史趋势** | ◐ | ◐ | **✔** | ◐ | ✘ | **✘**(仅当前值 + 最近 5 分钟采样) |
|
||||
| **阈值型告警**(CPU/磁盘/连续失败) | ◐ | ✘ | **✔** | ✘ | ◐ | **✘**(只有事件型通知) |
|
||||
| 通知渠道数量 | ◐ | ◐ | ✔ | ✘ | ✔ | ◐(邮件 / Webhook / Telegram) |
|
||||
| **API Token**(脚本、HomeAssistant 调用) | ✔ | ✔ | ✔ | ✔ | ✔ | **✘**(仅会话 cookie) |
|
||||
| 多用户 / OIDC / 只读账号 | **✔** | ◐ | ✔ | ◐ | ✘ | **✘**(单管理员) |
|
||||
| 定时自动备份 + 保留策略 | ◐ | ◐ | **✔** | ✔ | ✘ | ◐(手动导出 / 恢复) |
|
||||
| Docker 容器统计 | ✘ | ✘ | **✔** | ✘ | ✘ | **✘** |
|
||||
| 加密配置导出 / 迁移包 | ◐ | ✘ | ✘ | **✔** | ✘ | ◐(脱敏导出,无导入) |
|
||||
|
||||
---
|
||||
|
||||
## 三、建议增补(按价值 / 成本排序)
|
||||
|
||||
### P0 · 成本低、直接补齐明显短板
|
||||
|
||||
1. **配置版本历史 + 对比 + 回滚**(学 nginx-ui)
|
||||
- 现状:`internal/fsutil.Backup` 只保留一份 `<path>.bak`;公网反代页已有「漂移检测」能看出「VPS 上的与记录不一致」,但没有历史可比。
|
||||
- 做法:把 `.bak` 升级为 `<path>.versions/<时间戳>.conf`(保留 N 份,沿用现有 `pruneBackups` 思路),复用公网反代页「漂移清单」的 UI 模式做「版本列表 + 差异高亮 + 一键回滚」。
|
||||
- 落点:`internal/fsutil`(新增 `BackupVersioned`)、`internal/nginx/custom.go`(已有 `backupFile`/`pruneBackups` 可复用)、`web/src/views/ProxyRemoteView.vue`。
|
||||
- 价值:误改配置后可一键回到上一个可用版本,比现在的「删掉重部署」可靠得多。
|
||||
|
||||
2. **API Token**(学 beszel / nginx-ui)
|
||||
- 现状:97 条 API 路由只有会话 cookie 一种认证;脚本、HomeAssistant、手机快捷指令都调不动。
|
||||
- 做法:`api_tokens` 表(名称 + 哈希 + 权限范围 + 最后使用时间),`RequireAuth` 中间件同时接受 `Authorization: Bearer`,设置页做管理(新建时只显示一次明文,沿用现有「掩码往返」约定)。
|
||||
- 落点:`internal/api/middleware.go`、`internal/auth`、`web/src/views/SettingsView.vue`。
|
||||
- 注意:这是新增认证面,必须走一次安全自查(令牌哈希存储、撤销、只读范围)。
|
||||
|
||||
3. **通知渠道补齐国内常用通道**(学 ddns-go / ddns-updater)
|
||||
- 现状:邮件 / Webhook / Telegram。`web/src/constants/notifyProviders.ts` 里已有 `DEFAULT_BARK_SERVER`、`DEFAULT_NTFY_SERVER` 常量(说明预留了位置)。
|
||||
- 做法:补 Bark、ntfy、企业微信、钉钉、飞书(都是「POST 一个 URL + JSON」模式,可复用现有 Webhook 通道的发送路径)。
|
||||
- 落点:`internal/notify`、`web/src/constants/notifyProviders.ts`、`SettingsView.vue`。
|
||||
- 价值:NAS 用户最常用 Bark / 企业微信,成本极低。
|
||||
|
||||
4. **阈值型告警**(学 beszel)
|
||||
- 现状:通知全是事件型(DDNS 失败、证书到期、IP 频繁访问、登录失败、Nginx 重载失败),没有「CPU 持续 90%」「磁盘 85%」「隧道连续 3 次不通」这类。
|
||||
- 做法:复用 `internal/scheduler`(已有 `recover()` 与间隔校验)加一个指标巡检任务 + 冷却时间,阈值与开关走设置项(现有 `notify_*` 事件开关的模式可直接照搬)。
|
||||
- 落点:`internal/notify`、`internal/scheduler`、`internal/agent`(metrics 端点已存在,v1.2.4 加的)。
|
||||
|
||||
### P1 · 价值高、工作量中等
|
||||
|
||||
5. **指标历史与趋势图**(学 beszel)
|
||||
- 现状:VPS 资源、隧道流量都只有「当前值 + 最近 5 分钟内存采样」,重启即丢。
|
||||
- 做法:新增 `metrics_samples` 表(按 1 分钟落点 + 保留期设置),前端加 1h / 24h / 7d 切换(ECharts 与 `MiniTrafficChart` 已在用)。
|
||||
- 价值:NAS 场景最关心磁盘与带宽趋势,这是当前仪表盘最大的能力缺口。
|
||||
|
||||
6. **健康检查持续化 + 只读状态页**(学 uptime-kuma / beszel)
|
||||
- 现状:四段健康检查是「打开页面才算一次」,页面关掉就没有数据;`internal/agent/insights.go` 已有采样能力。
|
||||
- 做法:后台按间隔对每条反代规则做一轮检查并落库,连续失败 N 次才告警、恢复时再通知一次;可加一个可公开的只读状态页(按规则展示最近 24 小时可用率)。
|
||||
- 注意:状态页是**新增的匿名访问面**,必须单独设计(默认关闭、可设访问码)。
|
||||
|
||||
7. **定时自动备份 + 保留策略**(学 beszel / nginx-ui)
|
||||
- 现状:手动「导出 / 恢复」;恢复路径的校验与权限刚在 v1.2.7 收紧。
|
||||
- 做法:`scheduler` 加每日备份任务(写 `<DataDir>/backups`,保留 N 份,可选 WebDAV/S3 上传),设置页给保留份数。
|
||||
|
||||
8. **一键迁移包(加密导出 + 导入)**(学 nginx-ui 的加密配置导出)
|
||||
- 现状:只有「脱敏配置导出」(给用户看,不含密钥),换机器得手动重填服务端与证书。
|
||||
- 做法:导出含服务端 / 规则 / 证书 / 设置 / 密钥的加密包(口令派生密钥,用现有 `internal/secret`),导入时校验版本并落库。
|
||||
- 价值:换 NAS、重装系统、备份恢复三类场景都会用到。
|
||||
|
||||
9. **Docker 容器统计**(学 beszel)
|
||||
- 做法:agent 加只读 `GET /api/v1/docker`(容器列表 + CPU/内存/网络),公网服务端页加一张卡(`internal/sysinfo` 已有采集框架)。
|
||||
- 注意:agent 侧要显式限制只读、并处理「VPS 上没装 Docker」的降级。
|
||||
|
||||
### P2 · 可选
|
||||
|
||||
10. **只读账号 / OIDC**(学 pangolin):给家人看仪表盘、或接入已有的 SSO。属权限模型改动,建议在 API Token 落地、认证面稳定后再做。
|
||||
11. **DDNS 记录类型扩展**(学 ddns-go):显式支持 AAAA / CNAME / 多记录批量,并在 UI 上区分。
|
||||
12. **日志增强**:按域名 / 规则过滤、导出 CSV(现在有级别筛选、关键词搜索、分页与倒序)。
|
||||
13. **规则模板与克隆**:常用规则(HTTP 反代 + Basic Auth + 限流)存为模板,新建时一键套用。
|
||||
|
||||
---
|
||||
|
||||
## 四、现有功能的优化点(对标后回看自己)
|
||||
|
||||
| 位置 | 可优化 | 依据 |
|
||||
| --- | --- | --- |
|
||||
| 健康检查 | 现在每轮 7 个接口并行(v1.2.8 已改并行)+ 15 秒轮询;可把「健康检查」单独拉长到 60 秒、其余保持高频 | beszel 对采集频率按指标分层 |
|
||||
| 通知 | 事件型通知缺「冷却时间」,连续事件会连发(登录失败已有窗口统计,可推广到全部事件) | uptime-kuma / beszel 都有冷却与静默期 |
|
||||
| 证书 | 续期失败后的重试与提醒策略值得复核(到期提醒已有) | nginx-ui 强调「自动续期 + 失败可见」 |
|
||||
| 备份 | `internal/backup` 的写入方 `defer Close()` 不检查错误(审查报告 §二已列,P1 未做) | 与 nginx-ui 的「备份可用性」要求一致 |
|
||||
| 配置生成 | 反代配置由模板生成是**优点**(保证同源),不要为了对齐 nginx-ui 而开放全文在线编辑 | 本项目刚在 v1.2.8 收敛模板重复,开放全文编辑会破坏该约束 |
|
||||
| 前端 | 颜色令牌收敛(231 处)与 i18n 起步仍在待办 | 与对标项目无关,属自身债务 |
|
||||
|
||||
---
|
||||
|
||||
## 五、明确不建议跟进
|
||||
|
||||
1. **多租户 / 组织模型**(pangolin 面向团队):Fonu 是个人 / 家庭 NAS 工具,引入组织、成员、资源级策略会让设置页与数据模型复杂度翻倍。
|
||||
2. **Nginx 配置全文在线编辑**(nginx-ui 的路子):会破坏「反代配置由模板同源生成」这一核心保证;保留现有「自定义片段」入口即可。
|
||||
3. **集群 / 多节点镜像**(nginx-ui 的集群管理):Fonu 的 app + agent 已是多节点模型,再叠一层集群属于重复抽象。
|
||||
4. **MCP / AI 助手接入**(nginx-ui 新功能):与项目定位不符,且每个新接口都是新的攻击面,收益不明确。
|
||||
5. **把 frps 面板做成 frp-panel 那样的「多 frpc 集中管理」**:Fonu 的 frpc 就跑在本机 NAS 上(单一客户端 + 多服务端),不需要再管别人的 frpc。
|
||||
|
||||
---
|
||||
|
||||
## 六、建议实施顺序
|
||||
|
||||
1. 配置版本历史 + 回滚(P0-1)——与刚做完的 fsutil 收敛直接衔接,风险最低、收益最直观。
|
||||
2. 通知渠道补齐 + 阈值告警(P0-3、P0-4)——复用现有通知与调度框架,改动集中在 `internal/notify`。
|
||||
3. API Token(P0-2)——单独一轮,配一次安全自查。
|
||||
4. 指标历史与趋势图(P1-5)+ 健康检查持续化(P1-6)——需要新表与后台任务,建议同一轮做(共用落点与保留期设置)。
|
||||
5. 其余按需。
|
||||
|
||||
## 七、第二批方向(第二轮检索补充)
|
||||
|
||||
上一节偏「同域直接对标」;这一批来自相邻领域(零信任访问、状态页、加密备份、NAS 硬件监控、SSO/MFA、服务面板),都是 Fonu 已有模块的**能力延伸**,不需要新架构。
|
||||
|
||||
| 借鉴点 | 出处 | 落到 Fonu 哪里 | 价值 |
|
||||
| --- | --- | --- | --- |
|
||||
| **API Token 的到期 / 刷新 / 新旧密钥宽限期** | Cloudflare Zero Trust 的 Service Token | 补强 §三-P0-2:令牌加 `expires_at`、可刷新延长、轮换时旧密钥保留一段宽限期 | 让脚本 / HomeAssistant 的接入既可撤销又不会一改就断 |
|
||||
| **状态页 + 事故(incident)记录** | Gatus、Uptime Kuma | 与 §三-P1-6 合并:持续健康检查落库后,除「可用率」再输出「不可用区间」列表(什么时间、哪条规则、多久) | 「昨晚为什么打不开」这类问题有处可查 |
|
||||
| **Prometheus `/metrics` 导出** | Gatus | 新端点,把已有指标(VPS 资源、隧道流量、健康状态、证书剩余天数)按 Prometheus 文本格式暴露 | 一行配置就能接入 Grafana / HomeAssistant,比自建图表性价比高 |
|
||||
| **事件订阅(webhook 推事件)** | Cloudflare 的 Event subscriptions | 现有通知是「发给人」;再加「推给系统」(把规则部署 / 证书续期 / 漂移检测事件 POST 到外部 URL) | 对接自动化(如部署后触发 CDN 刷新) |
|
||||
| **2FA / TOTP + 登录设备与会话列表** | Authelia、Authentik | 补强 §三-P2-10:TOTP 绑定(二维码 + 一次性恢复码)、已登录设备列表与「踢下线」、登录历史 | Fonu 面板暴露在公网时,单密码是最大的残留风险;且与 v1.2.8 的「改密失效其他会话」天然衔接 |
|
||||
| **审计日志**(谁、何时、改了什么) | Authelia、Authentik | 新增 `audit_logs` 表,在服务端 / 规则 / 证书 / 设置的写入口记一条(含来源 IP) | 多设备 / 多人使用时能回溯误操作 |
|
||||
| **备份保留策略(GFS)+ 完整性校验** | restic / kopia | 补强 §三-P1-7:保留策略改成 `keep-daily/weekly/monthly`;恢复前先校验包内条目(数量 / 大小 / 时间)再落盘 | 备份不会因「保留太多份」撑爆磁盘,也不会拿半个包去覆盖现有数据 |
|
||||
| **本机 SMART 磁盘健康 + UPS 状态** | NAS Doctor、Scrutiny、PeaNUT | 扩展 `internal/sysinfo`(本机侧,不经 agent):磁盘 SMART(温度 / 重分配扇区 / 待处理扇区 / 通电小时)、UPS 电量与剩余时间 | NAS 用户的真痛点:硬盘将坏、市电断了;目前仪表盘只有 CPU / 内存 / 磁盘占用 |
|
||||
| **服务入口墙** | Homepage、Homarr、Glance | 仪表盘新增一区:把启用中的反代 / 穿透规则按域名聚合成可点击卡片(域名、类型、健康点),复用现有 `openDomain()` | NAS 用户装面板就是为了「一处点开所有服务」 |
|
||||
| **移动端 PWA + 命令面板** | Homepage、Dashy(kiosk 模式) | `manifest.webmanifest` + 图标,让手机「加到主屏」;再加 Cmd/Ctrl+K 跳页面 / 搜规则 | 手机上用得多,成本很低 |
|
||||
|
||||
**这一批里我建议先做的两项**:
|
||||
|
||||
1. **本机 SMART + UPS**(上表第 8 行)——Fonu 面向 NAS,而当前仪表盘完全没覆盖硬件健康;`internal/sysinfo` 已有平台分文件(`_linux.go` / `_windows.go`)的框架,加采集与一张卡即可,不碰 agent。
|
||||
2. **Prometheus 导出**(第 3 行)——把已有指标换个输出格式就能接 Grafana / HomeAssistant,比自建历史图表(§三-P1-5)成本低一个量级,两者可先做这个。
|
||||
|
||||
**仍然不建议跟的**(补充):把 Fonu 变成通用监控平台(Gatus / Uptime Kuma 那种「任意 URL 探活」)——它的健康检查应该围绕自己下发的反代规则,而不是变成一个通用探活器;引入 Redis 会话存储(Authelia / Authentik 的选择)也没必要,单机 SQLite 足够。
|
||||
|
||||
---
|
||||
|
||||
## 八、待评估:读取 VPS 实际放行端口(只读)
|
||||
|
||||
> 背景:现在 Fonu 只能**告诉用户该放行哪些端口**(frps 同源配置弹窗按已启用的 TCP/UDP 规则推导端口列表,`internal/frp/frps_template.go` 也生成 `allowPorts`),但读不到服务器上**实际放行**了什么。隧道连不上时最常见的原因正是端口未放行,而日志不会点明。
|
||||
|
||||
### 结论
|
||||
|
||||
**主机侧防火墙可以读,云厂商安全组读不到。**
|
||||
|
||||
| 来源 | 可行性 | 做法 |
|
||||
| --- | --- | --- |
|
||||
| `ufw`(Debian / Ubuntu) | ✅ | `ufw status numbered`(agent 以 root 运行,无需额外提权) |
|
||||
| `firewalld`(RHEL / CentOS) | ✅ | `firewall-cmd --list-ports` / `--list-services` / `--list-rich-rules` |
|
||||
| `nftables` / `iptables` 兜底 | ◐ | `nft list ruleset` / `iptables-save`——是 ufw/firewalld 的底层,直接读会看到大量实现细节,只作为「没有前两者时」的兑底 |
|
||||
| **云厂商安全组**(本机是 RainYun) | ❌ | 安全组在厂商控制台 / API,**主机侧不可见**;要读就得引入 AccessKey(新凭据面,不建议) |
|
||||
| 上游 NAT / 网络设备 | ❌ | 不在主机视野内 |
|
||||
|
||||
### 设计(只读,不提供任何写操作)
|
||||
|
||||
1. agent 新增 `GET /api/v1/firewall`(沿用 `s.auth`):返回 `{backend, rules: [{port, proto, source, action}], missing_tool, checked_at}`;先探测哪个前端存在,命令全部**固定 argv**(与现有 `systemctl` / `ss -tlnp` 的做法一致,不拼用户输入)。
|
||||
2. 应用侧 `AgentClient.Firewall()` + `GET /api/frpmulti/servers/{id}/agent/firewall`,与现有 agent 路由同构(`internal/api/router.go`)。
|
||||
3. 前端把「需要放行的端口」(现有规则推导逻辑)与「实际已放行」做**交叉对照**:直接标出「已放行 / 未放行」,并固定一行说明「云安全组需在厂商控制台确认(主机侧读不到)」。落点:`web/src/views/AgentView.vue`(frps.toml 弹窗与详情区)与 `FrpView.vue` 的规则区提示。
|
||||
4. 与已有数据交叉验证:agent 现在已在采集 `ss -tlnp` 的监听端口,可额外区分「已放行但无服务监听」与「有服务监听但未放行」。
|
||||
|
||||
### 边界与风险
|
||||
|
||||
- **只读**:不提供任何防火墙写操作,与 agent 能力方案 §六「防火墙规则读写不做(最多只读)」一致。
|
||||
- **降级**:命令不存在 / 无权限 / 无规则三种情况都要能降级成「未检测到防火墙管理工具」而不是报错。
|
||||
- **不引入云 API**:接厂商 API 意味着用户要交出 AccessKey,收益(省一次控制台确认)不值这个代价。
|
||||
- **agent 行为变更**:需递增 `AgentVersion`(0.12.2)并重编双架构内嵌二进制,VPS 上点「升级 Agent」才生效。
|
||||
|
||||
---
|
||||
|
||||
## 九、P0 实施状态(2026-09-24)
|
||||
|
||||
### 已完成:配置版本的后端基础
|
||||
|
||||
| 改动 | 位置 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `VersionsDir` / `BackupVersioned` / `pruneVersions` | `internal/fsutil/fsutil.go` | 版本存为 `<path>.versions/<UTC 时间戳.毫秒>-<随机 4 位>.conf`,按文件名倒序即新旧顺序;`keep <= 0` 不剪枝;文件不存在视为无需备份 |
|
||||
| 接入 Nginx 配置写入 | `internal/nginx/nginx.go` 的 `Apply` | 覆盖前先 `.bak`(保留最近一次,兼容既有读取方),再 `.versions/` 保留最近 10 份 |
|
||||
| 测试 | `internal/fsutil/fsutil_test.go` | 新增 3 例:内容正确、剪枝到 keep 份、文件不存在返回空且不报错 |
|
||||
|
||||
验证:`gofmt` 干净、`go build ./...` / `go vet` / `go test ./internal/fsutil/ ./internal/nginx/` 全绿。
|
||||
|
||||
### 已完成:版本列表 / 差异 / 回滚(本机侧与 agent 侧都做了)
|
||||
|
||||
| 改动 | 位置 |
|
||||
| --- | --- |
|
||||
| `ListVersions` / `ReadVersion`(名称校验拒绍上跳)、`BackupVersioned` 对相同内容去重 | `internal/fsutil/fsutil.go` + 4 个测试 |
|
||||
| `ReadConfig` / `ListConfigVersions` / `ReadConfigVersion` / `RollbackConfigVersion`(先语法校验 + `nginx -t`,不过关不覆盖);把 Apply 的「让它生效」抽成 `activate` 供回滚复用 | `internal/nginx/versions.go`、`nginx.go` |
|
||||
| `GET /api/settings/nginx/config/versions`、`GET …/{name}`、`POST …/{name}/rollback` | `internal/api/nginx_handler.go`、`router.go`、`internal/service/nginx_config.go` |
|
||||
| 设置页「Nginx 全局配置」卡内新增「主配置版本与回滚」:版本列表 + 逐行差异 + 二次确认回滚 | `web/src/views/SettingsView.vue` |
|
||||
| agent 侧:写 vhost 前留版本;**校验失败改为恢复上一个可用版本**(此前直接删文件,等于把站点从磁盘上抹掉);新增 `GET /routes/{domain}/versions`、`GET …/versions/{name}`、`POST …/versions/{name}/rollback`;删路由时一并清版本目录 | `internal/agent/versions.go`、`server.go`(`AgentVersion` → **0.12.2**,双架构二进制已重编) |
|
||||
| 应用侧:`AgentClient.RouteVersions/RouteVersion/RollbackRouteVersion` + 三个 `/api/frpmulti/servers/{id}/agent/routes/{proxyID}/versions…` 路由(端点不存在时提示「需 ≥ 0.12.2」) | `internal/frp/agent_client.go`、`internal/api/frpmulti_handler.go`、`router.go` |
|
||||
| 前端:公网反代页「查看配置」弹窗内加版本面板 + 差异高亮 + 回滚二次确认 | `web/src/views/ProxyRemoteView.vue`、`components/ConfigDiffView.vue`、`utils/diff.ts` |
|
||||
|
||||
#### 原计划(已按此实现,保留作对照)
|
||||
|
||||
1. `internal/fsutil`:加 `ListVersions(path) ([]VersionEntry, error)`(名称 + 大小 + 修改时间,新的在前)与 `ReadVersion(path, name) ([]byte, error)`(拒绝带 `/` 或 `..` 的名称)。
|
||||
2. `internal/nginx`:`ListConfigVersions()` / `ReadConfigVersion(name)` / `RollbackConfigVersion(name)`——回滚必须先 `ValidateConfigSyntax` + `m.validate(ctx, tmp)` 通过再写回并 reload,失败不覆盖当前配置(与 Apply 同构)。
|
||||
3. `internal/api`:`GET /api/nginx/versions`、`GET /api/nginx/versions/{name}`、`POST /api/nginx/versions/{name}/rollback`(受保护路由,写入口径同现有 nginx 接口)。
|
||||
4. 前端:本地反代页 Nginx 配置区 + 公网反代页——版本列表(时间 / 大小)+ 逐行差异高亮(复用漂移清单的样式)+ 回滚二次确认。
|
||||
5. 边界:本轮只做**本机侧**;VPS 上的 `fonu-<域名>.conf` 版本历史属于 agent 侧,需单独一轮(agent 写路由时也调 `BackupVersioned`,再配一个只读列表端点)。
|
||||
|
||||
### 已完成:其余三项 P0(明细见文末「本轮完成明细」)
|
||||
|
||||
#### 原计划(已按此实现,保留作对照)
|
||||
|
||||
- **API Token**:表 `api_tokens(id, name, token_hash, scopes, last_used_at, expires_at, created_at)`;`RequireAuth` 同时接受 `Authorization: Bearer`;新建时只显示一次明文(沿用现有「掩码往返」约定);设置页管理;落地前过一遍安全自查(哈希存储、可撤销、只读范围、`last_used_at` 审计)。建议单独一轮。
|
||||
- **通知渠道**:现有 email / webhook / telegram 三通道的抽象(`SaveInput` / `ParseSaveInputFromMap` / `validateSaveInput`)→ 新增 Bark / ntfy / 企业微信 / 钉钉 / 飞书(都是 POST URL + JSON);`web/src/constants/notifyProviders.ts` 已有 `DEFAULT_BARK_SERVER` / `DEFAULT_NTFY_SERVER` 常量可用。
|
||||
- **阈值告警**:`internal/scheduler` 加指标巡检任务(数据源:agent `GET /api/v1/metrics` + 本机 `internal/sysinfo`)→ 阈值 / 开关 / 冷却时间走设置项(照搬 `notify_*` 事件开关模式);冷却状态可先放内存(重启丢可接受)。
|
||||
|
||||
建议顺序:版本回滚 → 通知渠道 → 阈值告警 → API Token(本轮已全部完成)。
|
||||
|
||||
### 本轮完成明细
|
||||
|
||||
**通知渠道补齐**
|
||||
|
||||
- Bark / ntfy / Gotify **本来就有**(上文「只有邮件 / Webhook / Telegram」不准确);本轮补的是**企业微信机器人、钉钉机器人、飞书机器人**:固定路径 + JSON,`Key` 存 `Webhook.Key`、`Server` 可覆盖默认域名。
|
||||
- 钉钉正文固定以「Fonu · 」开头,机器人用「自定义关键词 = Fonu」即可命中(加签方式暂不支持,UI 已注明)。
|
||||
- `providerUsesKey()` 收敛了「凭据放 Key 还是 WebhookSecret」的判定,保存与落库共用一处。
|
||||
- 前端:预设下拉 + 三家各自的字段与提示;**切换预设时服务地址自动换成该预设默认值**(否则会把 `api.day.app` 带到企业微信上)。
|
||||
- 测试:`push_test.go` 锁定三家的端点与 payload 形状。
|
||||
|
||||
**阈值型告警**
|
||||
|
||||
- 新包 `internal/monitor`:`Checker.Run` 每 2 分钟跑一轮(`internal/app/app.go` 注册为 scheduler 任务)。
|
||||
- 资源:CPU / 内存 / 磁盘超阈值告警,同一项 30 分钟静默期,回落到阈值以下即清冷却(再次超标可立即再报)。
|
||||
- 隧道:连续 N 次巡检未在 frps 上注册才告警(默认 3 次),**只在刚好达到阈值与恢复时各发一次**,不刷屏。
|
||||
- 事件与设置项:`on_resource_threshold` / `on_tunnel_down`(默认**关闭**,升级后不改变现有用户收到的通知)+ `notify_cpu_threshold`(90) / `notify_memory_threshold`(90) / `notify_disk_threshold`(85) / `notify_tunnel_fail_threshold`(3);开关关闭时连采集与探测都跳过。
|
||||
- 前端:告警事件新增「资源占用过高」「隧道连续不通」两项与对应阈值输入。
|
||||
- 测试:`monitor_test.go` 覆盖「只报一次 / 恢复报一次 / 未达阈值静默」。
|
||||
|
||||
**API Token**
|
||||
|
||||
- 迁移 `025_api_tokens.sql`:`api_tokens(id, name, token_hash, scope, expires_at, last_used_at, created_at)`,`token_hash` 是明文令牌的 SHA-256(令牌是 `crypto/rand` 的 32 字节随机串,熵足够,不需要 bcrypt);明文只在创建响应里回一次。
|
||||
- `RequireAuth` 同时接受会话 cookie 与 `Authorization: Bearer <token>`;`read` 范围只允许 GET/HEAD,`write` 与会话同权。
|
||||
- **安全自查**:① 令牌管理(`/api/settings/tokens`)与改密 / 退出**只允许登录会话**——否则一个 write 令牌能给自己提权或无限续期;② 撤销即删行、立即失效;③ 可选有效期(最长 3650 天,空为永不过期);④ `last_used_at` 限频回写(1 分钟一次,单连接 SQLite 上不做每请求写)。
|
||||
- 接口:`GET/POST /api/settings/tokens`、`DELETE /api/settings/tokens/{id}`;设置页「高级」新增「API Token」卡:列表(名称 / 权限 / 创建 / 最近使用 / 有效期)+ 创建表单 + 明文一次性展示与复制 + 撤销确认。
|
||||
- 测试:`internal/auth/token_test.go`(生命周期 / 过期 / 非法输入 / 明文不落盘)、`internal/api/middleware_test.go`(只读令牌只读、写令牌可写、令牌不能管理令牌、错误凭据 401)。
|
||||
|
||||
**本轮验证**
|
||||
|
||||
`gofmt`(改动文件干净)、`go build ./...`、`go vet ./...`、`go test ./... -count=1` 全绿;`npx vue-tsc --noEmit` 0 错误、`npx vite build` 成功;`internal/frp/agentbin/` 双架构二进制已重编(含 `0.12.2`)。
|
||||
|
||||
**待用户操作**
|
||||
|
||||
1. **重启本机 `bin\fonu.exe`** 才能看到新接口与新 UI(新迁移在启动时自动执行)。
|
||||
2. **VPS 上点「升级 Agent」** 才会装上 0.12.2,公网反代页的版本历史才会出现(否则提示「需 ≥ 0.12.2」)。
|
||||
3. 已随本轮提交:`CHANGELOG.md` 新增 v1.2.9 段,`VERSION` 与 `web/package.json` 同步到 1.2.9。
|
||||
|
||||
---
|
||||
|
||||
> 说明:本文件是**建议清单**,未改动任何代码;实施时按本仓库既有约定(每步验证 `go build/vet/test` + `vue-tsc/vite build`、agent 行为变更递增 `AgentVersion`、提交同批更新 `CHANGELOG` 与 `VERSION`)。
|
||||
@@ -40,7 +40,8 @@ import (
|
||||
// 0.12.0:frps 安装包的 proxy 参数改白名单 + 下载后校验 SHA-256;Basic Auth 自动生成的密码不再写进
|
||||
// nginx 配置注释(改为只在 PUT 响应里回一次)。
|
||||
// 0.12.1:frps 启停失败如实回报(不再无条件写「已启动」);HTTP 服务补 ReadTimeout/IdleTimeout。
|
||||
const AgentVersion = "0.12.1"
|
||||
// 0.12.2:新增域名 vhost 的版本列表 / 对比 / 回滚端点;写配置前留版本,校验失败时恢复上一个可用版本。
|
||||
const AgentVersion = "0.12.2"
|
||||
|
||||
// Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
|
||||
type Config struct {
|
||||
@@ -167,6 +168,9 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /api/v1/routes", s.auth(s.routes))
|
||||
mux.HandleFunc("PUT /api/v1/routes", s.auth(s.putRoute))
|
||||
mux.HandleFunc("GET /api/v1/routes/{domain}/conf", s.auth(s.routeConf))
|
||||
mux.HandleFunc("GET /api/v1/routes/{domain}/versions", s.auth(s.routeVersions))
|
||||
mux.HandleFunc("GET /api/v1/routes/{domain}/versions/{name}", s.auth(s.routeVersion))
|
||||
mux.HandleFunc("POST /api/v1/routes/{domain}/versions/{name}/rollback", s.auth(s.rollbackRoute))
|
||||
mux.HandleFunc("PUT /api/v1/china-cidr", s.auth(s.putChinaCIDR))
|
||||
mux.HandleFunc("POST /api/v1/nginx/install", s.auth(s.installNginx))
|
||||
mux.HandleFunc("DELETE /api/v1/routes/{domain}", s.auth(s.deleteRoute))
|
||||
@@ -862,6 +866,8 @@ func (s *Server) deleteRoute(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 版本历史跟着 vhost 一起清掉,避免同域名重新部署后看到上一轮部署的旧版本
|
||||
_ = os.RemoveAll(fsutil.VersionsDir(path))
|
||||
if err := s.reloadNginx(); err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||
return
|
||||
@@ -1086,13 +1092,9 @@ func (s *Server) writeRoute(req routeRequest) error {
|
||||
if err := s.applyLimitState(req); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := atomicWrite(path, []byte(content), 0o640); err != nil {
|
||||
if err := s.applyRouteContent(path, []byte(content)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.validateNginx(); err != nil {
|
||||
_ = os.Remove(path)
|
||||
return fmt.Errorf("nginx config rejected: %w", err)
|
||||
}
|
||||
return s.reloadNginx()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/fsutil"
|
||||
)
|
||||
|
||||
// maxRouteVersions 每个域名 vhost 保留的版本份数。内容相同的写入会被 fsutil 去重,
|
||||
// 因此这里对应的是最近 10 次「配置真的变了」的改动。
|
||||
const maxRouteVersions = 10
|
||||
|
||||
type routeVersionInfo struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// routeVersions 返回某域名的当前配置与历史版本,供应用侧把「漂移」与「历史」放在一起看。
|
||||
func (s *Server) routeVersions(w http.ResponseWriter, r *http.Request) {
|
||||
domain := strings.ToLower(strings.TrimSpace(r.PathValue("domain")))
|
||||
if !validDomain(domain) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid domain"})
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
path := s.routePath(domain)
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "该域名尚未部署反代"})
|
||||
return
|
||||
}
|
||||
versions, err := fsutil.ListVersions(path)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"domain": domain,
|
||||
"content": string(content),
|
||||
"versions": mapRouteVersions(versions),
|
||||
})
|
||||
}
|
||||
|
||||
// routeVersion 返回某个历史版本的内容(前端拿它与当前配置做差异对比)。
|
||||
func (s *Server) routeVersion(w http.ResponseWriter, r *http.Request) {
|
||||
domain := strings.ToLower(strings.TrimSpace(r.PathValue("domain")))
|
||||
if !validDomain(domain) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid domain"})
|
||||
return
|
||||
}
|
||||
name := r.PathValue("name")
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
content, err := fsutil.ReadVersion(s.routePath(domain), name)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"domain": domain, "name": name, "content": string(content)})
|
||||
}
|
||||
|
||||
// rollbackRoute 把某域名的 vhost 回退到指定版本。
|
||||
func (s *Server) rollbackRoute(w http.ResponseWriter, r *http.Request) {
|
||||
domain := strings.ToLower(strings.TrimSpace(r.PathValue("domain")))
|
||||
if !validDomain(domain) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid domain"})
|
||||
return
|
||||
}
|
||||
name := r.PathValue("name")
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
path := s.routePath(domain)
|
||||
content, err := fsutil.ReadVersion(path, name)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := s.applyRouteContent(path, content); err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := s.reloadNginx(); err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "domain": domain, "name": name})
|
||||
}
|
||||
|
||||
// applyRouteContent 写入 vhost 内容并做 nginx -t 校验。
|
||||
//
|
||||
// 写之前先把当前内容留成一个版本;校验失败时恢复回上一个可用版本(此前没有文件才删除)。
|
||||
// 旧行为是校验失败直接删文件——那等于把已经在跑的站点从磁盘上抹掉,只能靠重新部署救回来。
|
||||
func (s *Server) applyRouteContent(path string, content []byte) error {
|
||||
previous, readErr := os.ReadFile(path)
|
||||
if _, err := fsutil.BackupVersioned(path, maxRouteVersions); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := atomicWrite(path, content, 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.validateNginx(); err != nil {
|
||||
if readErr == nil {
|
||||
_ = atomicWrite(path, previous, 0o640)
|
||||
} else {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
return fmt.Errorf("nginx config rejected: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mapRouteVersions(entries []fsutil.VersionEntry) []routeVersionInfo {
|
||||
out := make([]routeVersionInfo, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
out = append(out, routeVersionInfo{Name: entry.Name, Size: entry.Size, CreatedAt: entry.CreatedAt})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -398,38 +398,105 @@ func (h *FRPMultiHandler) AgentRoutes(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, details)
|
||||
}
|
||||
|
||||
// AgentRouteConf 返回指定穿透规则(首个域名)当前反代配置内容。
|
||||
func (h *FRPMultiHandler) AgentRouteConf(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
// agentRouteTarget 解析服务端与规则归属,返回该规则用于公网反代的首个域名。
|
||||
// status 非 0 表示校验失败,调用方直接把 status 与 errMsg 写回响应。
|
||||
func (h *FRPMultiHandler) agentRouteTarget(r *http.Request, serverIDRaw, proxyIDRaw string) (serverID, proxyID int64, domain string, status int, errMsg string) {
|
||||
serverID, err := parseID(serverIDRaw)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||
return
|
||||
return 0, 0, "", http.StatusBadRequest, "FRP 服务端 ID 无效"
|
||||
}
|
||||
proxyID, err := parseID(r.PathValue("proxyID"))
|
||||
proxyID, err = parseID(proxyIDRaw)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "FRP 规则 ID 无效")
|
||||
return
|
||||
return 0, 0, "", http.StatusBadRequest, "FRP 规则 ID 无效"
|
||||
}
|
||||
proxy, err := h.svc.GetProxy(r.Context(), proxyID)
|
||||
if err != nil || proxy.ServerID != id {
|
||||
writeError(r, w, http.StatusNotFound, "FRP 规则不存在")
|
||||
return
|
||||
if err != nil || proxy.ServerID != serverID {
|
||||
return 0, 0, "", http.StatusNotFound, "FRP 规则不存在"
|
||||
}
|
||||
if len(proxy.CustomDomains) == 0 {
|
||||
writeError(r, w, http.StatusBadRequest, "该规则没有自定义域名")
|
||||
return 0, 0, "", http.StatusBadRequest, "该规则没有自定义域名"
|
||||
}
|
||||
return serverID, proxyID, proxy.CustomDomains[0], 0, ""
|
||||
}
|
||||
|
||||
// AgentRouteConf 返回指定穿透规则(首个域名)当前反代配置内容。
|
||||
func (h *FRPMultiHandler) AgentRouteConf(w http.ResponseWriter, r *http.Request) {
|
||||
serverID, proxyID, domain, status, msg := h.agentRouteTarget(r, r.PathValue("id"), r.PathValue("proxyID"))
|
||||
if status != 0 {
|
||||
writeError(r, w, status, msg)
|
||||
return
|
||||
}
|
||||
client, ok := h.svc.AgentClient(r.Context(), id)
|
||||
client, ok := h.svc.AgentClient(r.Context(), serverID)
|
||||
if !ok {
|
||||
writeError(r, w, http.StatusBadGateway, "公网 agent 未配置或 Token 未保存")
|
||||
return
|
||||
}
|
||||
content, err := client.RouteConf(r.Context(), proxy.CustomDomains[0])
|
||||
content, err := client.RouteConf(r.Context(), domain)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"proxy_id": proxyID, "domain": proxy.CustomDomains[0], "content": content})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"proxy_id": proxyID, "domain": domain, "content": content})
|
||||
}
|
||||
|
||||
// AgentRouteVersions 返回规则首个域名在 VPS 上的当前 vhost 与历史版本,供差异对比与回滚。
|
||||
func (h *FRPMultiHandler) AgentRouteVersions(w http.ResponseWriter, r *http.Request) {
|
||||
serverID, _, domain, status, msg := h.agentRouteTarget(r, r.PathValue("id"), r.PathValue("proxyID"))
|
||||
if status != 0 {
|
||||
writeError(r, w, status, msg)
|
||||
return
|
||||
}
|
||||
client, ok := h.svc.AgentClient(r.Context(), serverID)
|
||||
if !ok {
|
||||
writeError(r, w, http.StatusBadGateway, "公网 agent 未配置或 Token 未保存")
|
||||
return
|
||||
}
|
||||
result, err := client.RouteVersions(r.Context(), domain)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// AgentRouteVersion 读取某个历史版本的内容(与当前 vhost 对比用)。
|
||||
func (h *FRPMultiHandler) AgentRouteVersion(w http.ResponseWriter, r *http.Request) {
|
||||
serverID, _, domain, status, msg := h.agentRouteTarget(r, r.PathValue("id"), r.PathValue("proxyID"))
|
||||
if status != 0 {
|
||||
writeError(r, w, status, msg)
|
||||
return
|
||||
}
|
||||
client, ok := h.svc.AgentClient(r.Context(), serverID)
|
||||
if !ok {
|
||||
writeError(r, w, http.StatusBadGateway, "公网 agent 未配置或 Token 未保存")
|
||||
return
|
||||
}
|
||||
result, err := client.RouteVersion(r.Context(), domain, r.PathValue("name"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// AgentRollbackRouteVersion 让 VPS 上的 vhost 回退到指定版本(agent 侧校验不过会保持当前配置)。
|
||||
func (h *FRPMultiHandler) AgentRollbackRouteVersion(w http.ResponseWriter, r *http.Request) {
|
||||
serverID, _, domain, status, msg := h.agentRouteTarget(r, r.PathValue("id"), r.PathValue("proxyID"))
|
||||
if status != 0 {
|
||||
writeError(r, w, status, msg)
|
||||
return
|
||||
}
|
||||
client, ok := h.svc.AgentClient(r.Context(), serverID)
|
||||
if !ok {
|
||||
writeError(r, w, http.StatusBadGateway, "公网 agent 未配置或 Token 未保存")
|
||||
return
|
||||
}
|
||||
result, err := client.RollbackRouteVersion(r.Context(), domain, r.PathValue("name"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// AgentInstallNginx 触发 VPS 上一键安装 Nginx(需 agent 以 root 运行)。
|
||||
|
||||
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/fonu/fonu/internal/auth"
|
||||
)
|
||||
@@ -24,9 +25,30 @@ func SessionMiddleware(authSvc *auth.Service) func(http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAuth 接受两种凭据:浏览器的会话 cookie,以及 Authorization: Bearer <api token>。
|
||||
// 令牌分读写两种范围:read 只允许 GET/HEAD,write 与会话同权。
|
||||
func RequireAuth(authSvc *auth.Service) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if token := bearerToken(r); token != "" {
|
||||
scope, err := authSvc.ValidateAPIToken(r.Context(), token)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusUnauthorized, "API Token 无效或已过期")
|
||||
return
|
||||
}
|
||||
// 账号与令牌管理只允许浏览器会话:否则一个 write 令牌可以自己给自己提权
|
||||
if tokenDeniedPath(r.URL.Path) {
|
||||
writeError(r, w, http.StatusForbidden, "该操作需要使用登录会话")
|
||||
return
|
||||
}
|
||||
if scope != auth.TokenScopeWrite && r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
writeError(r, w, http.StatusForbidden, "该 Token 只读,不能执行写操作")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
sessionID, _ := r.Context().Value(sessionContextKey).(string)
|
||||
if err := authSvc.ValidateSession(r.Context(), sessionID); err != nil {
|
||||
writeError(r, w, http.StatusUnauthorized, "未登录或会话已过期")
|
||||
@@ -37,6 +59,24 @@ func RequireAuth(authSvc *auth.Service) func(http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// bearerToken 取出 Authorization: Bearer 里的明文令牌;没有或格式不对返回空串。
|
||||
func bearerToken(r *http.Request) string {
|
||||
header := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||
const prefix = "Bearer "
|
||||
if len(header) <= len(prefix) || !strings.EqualFold(header[:len(prefix)], prefix) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(header[len(prefix):])
|
||||
}
|
||||
|
||||
// tokenDeniedPath 是只允许浏览器会话的路径(账号与令牌管理)。
|
||||
func tokenDeniedPath(path string) bool {
|
||||
if path == "/api/auth/password" || path == "/api/auth/logout" {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(path, "/api/settings/tokens")
|
||||
}
|
||||
|
||||
func sessionIDFromContext(ctx context.Context) string {
|
||||
v, _ := ctx.Value(sessionContextKey).(string)
|
||||
return v
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/auth"
|
||||
"github.com/fonu/fonu/internal/db"
|
||||
)
|
||||
|
||||
func newTokenTestAuth(t *testing.T, scope string) (*auth.Service, string) {
|
||||
t.Helper()
|
||||
conn, err := db.Open(filepath.Join(t.TempDir(), "fonu.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
svc := auth.New(conn)
|
||||
created, err := svc.CreateAPIToken(context.Background(), "test", scope, time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("create token: %v", err)
|
||||
}
|
||||
return svc, created.Token
|
||||
}
|
||||
|
||||
func okTokenHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }
|
||||
}
|
||||
|
||||
func TestRequireAuthAcceptsReadTokenForGet(t *testing.T) {
|
||||
svc, plain := newTokenTestAuth(t, auth.TokenScopeRead)
|
||||
handler := RequireAuth(svc)(okTokenHandler())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/status", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+plain)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("只读令牌应能读,实际 %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAuthBlocksWriteForReadToken(t *testing.T) {
|
||||
svc, plain := newTokenTestAuth(t, auth.TokenScopeRead)
|
||||
handler := RequireAuth(svc)(okTokenHandler())
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/proxies/1", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+plain)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("只读令牌不应能写,实际 %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAuthAllowsWriteForWriteToken(t *testing.T) {
|
||||
svc, plain := newTokenTestAuth(t, auth.TokenScopeWrite)
|
||||
handler := RequireAuth(svc)(okTokenHandler())
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/proxies", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+plain)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("写令牌应能写,实际 %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAuthTokenCannotManageTokens(t *testing.T) {
|
||||
// 写令牌也不能管理令牌:否则等于拿令牌给自己提权或无限续期
|
||||
svc, plain := newTokenTestAuth(t, auth.TokenScopeWrite)
|
||||
handler := RequireAuth(svc)(okTokenHandler())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/settings/tokens", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+plain)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("令牌管理应只允许登录会话,实际 %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAuthRejectsBadCredentials(t *testing.T) {
|
||||
svc, _ := newTokenTestAuth(t, auth.TokenScopeRead)
|
||||
handler := RequireAuth(svc)(okTokenHandler())
|
||||
|
||||
cases := map[string]string{
|
||||
"未知令牌": "Bearer deadbeef",
|
||||
"错误前缀": "Token abc",
|
||||
"没有凭据": "",
|
||||
}
|
||||
for name, header := range cases {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/status", nil)
|
||||
if header != "" {
|
||||
req.Header.Set("Authorization", header)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("%s 应返回 401,实际 %d", name, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBearerTokenParsing(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"Bearer abc": "abc",
|
||||
"bearer abc": "abc",
|
||||
"Bearer abc": "abc",
|
||||
"Basic abc": "",
|
||||
"Bearer ": "",
|
||||
"": "",
|
||||
}
|
||||
for header, want := range cases {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
if header != "" {
|
||||
req.Header.Set("Authorization", header)
|
||||
}
|
||||
if got := bearerToken(req); got != want {
|
||||
t.Fatalf("Authorization %q 解析为 %q,期望 %q", header, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,33 @@ func (h *NginxHandler) RollbackGlobal(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
func (h *NginxHandler) GetConfigVersions(w http.ResponseWriter, r *http.Request) {
|
||||
view, err := h.proxy.GetNginxConfigVersions()
|
||||
if err != nil {
|
||||
writeServiceError(r, w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
func (h *NginxHandler) GetConfigVersion(w http.ResponseWriter, r *http.Request) {
|
||||
view, err := h.proxy.ReadNginxConfigVersion(r.PathValue("name"))
|
||||
if err != nil {
|
||||
writeServiceError(r, w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
func (h *NginxHandler) RollbackConfigVersion(w http.ResponseWriter, r *http.Request) {
|
||||
view, err := h.proxy.RollbackNginxConfigVersion(r.Context(), r.PathValue("name"))
|
||||
if err != nil {
|
||||
writeServiceError(r, w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
func writeServiceError(r *http.Request, w http.ResponseWriter, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
|
||||
@@ -41,6 +41,9 @@ func NewRouter(deps Deps) http.Handler {
|
||||
}
|
||||
|
||||
protect("POST /api/auth/password", r.authHandler.ChangePassword)
|
||||
protect("GET /api/settings/tokens", r.authHandler.ListTokens)
|
||||
protect("POST /api/settings/tokens", r.authHandler.CreateToken)
|
||||
protect("DELETE /api/settings/tokens/{id}", r.authHandler.DeleteToken)
|
||||
protect("GET /api/status", r.statusHandler.Get)
|
||||
protect("GET /api/proxies", r.proxyHandler.List)
|
||||
protect("GET /api/proxies/traffic", r.proxyHandler.Traffic)
|
||||
@@ -56,6 +59,9 @@ func NewRouter(deps Deps) http.Handler {
|
||||
protect("GET /api/settings/nginx/global", r.nginxHandler.GetGlobal)
|
||||
protect("PUT /api/settings/nginx/global", r.nginxHandler.PutGlobal)
|
||||
protect("POST /api/settings/nginx/global/rollback", r.nginxHandler.RollbackGlobal)
|
||||
protect("GET /api/settings/nginx/config/versions", r.nginxHandler.GetConfigVersions)
|
||||
protect("GET /api/settings/nginx/config/versions/{name}", r.nginxHandler.GetConfigVersion)
|
||||
protect("POST /api/settings/nginx/config/versions/{name}/rollback", r.nginxHandler.RollbackConfigVersion)
|
||||
protect("GET /api/ddns", r.ddnsHandler.List)
|
||||
protect("POST /api/ddns", r.ddnsHandler.Create)
|
||||
protect("PUT /api/ddns/{id}", r.ddnsHandler.Update)
|
||||
@@ -125,6 +131,9 @@ func NewRouter(deps Deps) http.Handler {
|
||||
protect("POST /api/frpmulti/servers/{id}/agent/nginx/reload", frpHandler.AgentNginxReload)
|
||||
protect("POST /api/frpmulti/servers/{id}/agent/nginx/install", frpHandler.AgentInstallNginx)
|
||||
protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/conf", frpHandler.AgentRouteConf)
|
||||
protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/versions", frpHandler.AgentRouteVersions)
|
||||
protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/versions/{name}", frpHandler.AgentRouteVersion)
|
||||
protect("POST /api/frpmulti/servers/{id}/agent/routes/{proxyID}/versions/{name}/rollback", frpHandler.AgentRollbackRouteVersion)
|
||||
protect("GET /api/frpmulti/servers/{id}/agent/route-candidates", frpHandler.AgentRouteCandidates)
|
||||
protect("POST /api/frpmulti/servers/{id}/agent/routes/{proxyID}", frpHandler.DeployAgentRoute)
|
||||
protect("DELETE /api/frpmulti/servers/{id}/agent/routes/{proxyID}", frpHandler.RemoveAgentRoute)
|
||||
|
||||
@@ -39,25 +39,25 @@ func (h *SettingsHandler) Put(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
allowed := map[string]bool{
|
||||
settings.KeyTheme: true,
|
||||
settings.KeyTimezone: true,
|
||||
settings.KeyLogRetentionDays: true,
|
||||
settings.KeyDDNSCheckInterval: true,
|
||||
settings.KeyCertRenewThreshold: true,
|
||||
settings.KeyRootDomain: true,
|
||||
settings.KeyACMEEmail: true,
|
||||
settings.KeyACMECA: true,
|
||||
settings.KeyZeroSSLAPIKey: true,
|
||||
settings.KeyNotifyWebhookURL: true,
|
||||
settings.KeyNotifyType: true,
|
||||
settings.KeyNotifyEmailJSON: true,
|
||||
settings.KeyNotifySMTPPassword: true,
|
||||
settings.KeyNotifyWebhookJSON: true,
|
||||
settings.KeyNotifyWebhookSecret: true,
|
||||
settings.KeyNotifyTelegramJSON: true,
|
||||
settings.KeyNotifyTelegramToken: true,
|
||||
settings.KeyNotifyOnDDNSIPChange: true,
|
||||
settings.KeyNotifyOnDDNSFailure: true,
|
||||
settings.KeyTheme: true,
|
||||
settings.KeyTimezone: true,
|
||||
settings.KeyLogRetentionDays: true,
|
||||
settings.KeyDDNSCheckInterval: true,
|
||||
settings.KeyCertRenewThreshold: true,
|
||||
settings.KeyRootDomain: true,
|
||||
settings.KeyACMEEmail: true,
|
||||
settings.KeyACMECA: true,
|
||||
settings.KeyZeroSSLAPIKey: true,
|
||||
settings.KeyNotifyWebhookURL: true,
|
||||
settings.KeyNotifyType: true,
|
||||
settings.KeyNotifyEmailJSON: true,
|
||||
settings.KeyNotifySMTPPassword: true,
|
||||
settings.KeyNotifyWebhookJSON: true,
|
||||
settings.KeyNotifyWebhookSecret: true,
|
||||
settings.KeyNotifyTelegramJSON: true,
|
||||
settings.KeyNotifyTelegramToken: true,
|
||||
settings.KeyNotifyOnDDNSIPChange: true,
|
||||
settings.KeyNotifyOnDDNSFailure: true,
|
||||
settings.KeyNotifyOnCertExpiry: true,
|
||||
settings.KeyNotifyOnCertRenewSuccess: true,
|
||||
settings.KeyNotifyOnIPFrequentAccess: true,
|
||||
@@ -68,12 +68,18 @@ func (h *SettingsHandler) Put(w http.ResponseWriter, r *http.Request) {
|
||||
settings.KeyNotifyIPFrequentWindowSec: true,
|
||||
settings.KeyNotifyLoginFailureThreshold: true,
|
||||
settings.KeyNotifyLoginFailureWindowSec: true,
|
||||
settings.KeyTrustedProxy: true,
|
||||
settings.KeyGlobalIPBlacklist: true,
|
||||
settings.KeyGlobalIPWhitelist: true,
|
||||
settings.KeyChinaCIDRUpdateHours: true,
|
||||
settings.KeyChinaCIDRSourceV4: true,
|
||||
settings.KeyChinaCIDRSourceV6: true,
|
||||
settings.KeyNotifyOnResourceThreshold: true,
|
||||
settings.KeyNotifyOnTunnelDown: true,
|
||||
settings.KeyNotifyCPUThreshold: true,
|
||||
settings.KeyNotifyMemoryThreshold: true,
|
||||
settings.KeyNotifyDiskThreshold: true,
|
||||
settings.KeyNotifyTunnelFailThreshold: true,
|
||||
settings.KeyTrustedProxy: true,
|
||||
settings.KeyGlobalIPBlacklist: true,
|
||||
settings.KeyGlobalIPWhitelist: true,
|
||||
settings.KeyChinaCIDRUpdateHours: true,
|
||||
settings.KeyChinaCIDRSourceV4: true,
|
||||
settings.KeyChinaCIDRSourceV6: true,
|
||||
}
|
||||
filtered := map[string]string{}
|
||||
for key, value := range req {
|
||||
@@ -139,6 +145,12 @@ func isNotifySettingKey(key string) bool {
|
||||
settings.KeyNotifyIPFrequentWindowSec,
|
||||
settings.KeyNotifyLoginFailureThreshold,
|
||||
settings.KeyNotifyLoginFailureWindowSec,
|
||||
settings.KeyNotifyOnResourceThreshold,
|
||||
settings.KeyNotifyOnTunnelDown,
|
||||
settings.KeyNotifyCPUThreshold,
|
||||
settings.KeyNotifyMemoryThreshold,
|
||||
settings.KeyNotifyDiskThreshold,
|
||||
settings.KeyNotifyTunnelFailThreshold,
|
||||
settings.KeyNotifyWebhookURL:
|
||||
return true
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/auth"
|
||||
)
|
||||
|
||||
// 令牌管理挂在 AuthHandler 上:它已经持有 auth.Service,不必再加一层 handler。
|
||||
const maxTokenExpiryDays = 3650
|
||||
|
||||
type createTokenRequest struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
ExpiresInDays int `json:"expires_in_days"`
|
||||
}
|
||||
|
||||
func (h *AuthHandler) ListTokens(w http.ResponseWriter, r *http.Request) {
|
||||
tokens, err := h.auth.ListAPITokens(r.Context())
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "读取令牌失败", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"tokens": tokens})
|
||||
}
|
||||
|
||||
// CreateToken 新建令牌;明文只在这一个响应里返回,之后无法再取回。
|
||||
func (h *AuthHandler) CreateToken(w http.ResponseWriter, r *http.Request) {
|
||||
var req createTokenRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||
return
|
||||
}
|
||||
scope := strings.TrimSpace(req.Scope)
|
||||
if scope == "" {
|
||||
scope = auth.TokenScopeRead
|
||||
}
|
||||
var expiresAt time.Time
|
||||
if req.ExpiresInDays > 0 {
|
||||
if req.ExpiresInDays > maxTokenExpiryDays {
|
||||
writeError(r, w, http.StatusBadRequest, "有效期最长 3650 天")
|
||||
return
|
||||
}
|
||||
expiresAt = time.Now().AddDate(0, 0, req.ExpiresInDays)
|
||||
}
|
||||
token, err := h.auth.CreateAPIToken(r.Context(), req.Name, scope, expiresAt)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, token)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) DeleteToken(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "无效的令牌 ID")
|
||||
return
|
||||
}
|
||||
if err := h.auth.DeleteAPIToken(r.Context(), id); err != nil {
|
||||
if err.Error() == "令牌不存在" {
|
||||
writeError(r, w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
writeError(r, w, http.StatusInternalServerError, "删除令牌失败", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/fonu/fonu/internal/discovery"
|
||||
"github.com/fonu/fonu/internal/frp"
|
||||
"github.com/fonu/fonu/internal/logstore"
|
||||
"github.com/fonu/fonu/internal/monitor"
|
||||
"github.com/fonu/fonu/internal/nginx"
|
||||
"github.com/fonu/fonu/internal/notify"
|
||||
"github.com/fonu/fonu/internal/proxy"
|
||||
@@ -138,6 +139,26 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
|
||||
if chinaHours <= 0 {
|
||||
chinaHours = 24
|
||||
}
|
||||
// 资源与隧道巡检:间隔比其它任务短,「连续 N 次不通」需要足够采样次数才能判定
|
||||
monitorChecker := monitor.New(settingsStore, notifySvc, logger, func(ctx context.Context) ([]monitor.RouteStatus, error) {
|
||||
servers, err := frpMultiSvc.ListServers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
routes := make([]monitor.RouteStatus, 0, len(servers))
|
||||
for _, server := range servers {
|
||||
// 单台服务端探测失败不影响其它服务端:它本轮跳过,等下一轮
|
||||
health, err := frpMultiSvc.RouteHealth(ctx, server.ID)
|
||||
if err != nil {
|
||||
logger.Warn("巡检跳过服务端", "module", "MONITOR", "server_id", server.ID, "error", err.Error())
|
||||
continue
|
||||
}
|
||||
for _, item := range health {
|
||||
routes = append(routes, monitor.RouteStatus{Domain: item.Domain, TunnelOK: item.TunnelOK, Detail: item.TunnelDetail})
|
||||
}
|
||||
}
|
||||
return routes, nil
|
||||
})
|
||||
sched := scheduler.New(
|
||||
scheduler.Job{
|
||||
Name: "ddns",
|
||||
@@ -154,6 +175,11 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
|
||||
Interval: time.Duration(chinaHours) * time.Hour,
|
||||
Run: chinaCIDRSvc.Tick,
|
||||
},
|
||||
scheduler.Job{
|
||||
Name: "monitor",
|
||||
Interval: 2 * time.Minute,
|
||||
Run: monitorChecker.Run,
|
||||
},
|
||||
)
|
||||
sched.Start(ctx)
|
||||
trafficCollector.Start(ctx)
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// API Token 给无法保存会话 cookie 的调用方(脚本、HomeAssistant、手机快捷指令)用。
|
||||
//
|
||||
// 明文只在创建响应里返回一次,库里只存 SHA-256:令牌由 crypto/rand 生成、熵足够高,
|
||||
// 不像人类密码那样需要 bcrypt 这类慢哈希(慢哈希在这里只会拖慢每个 API 请求)。
|
||||
const (
|
||||
TokenScopeRead = "read"
|
||||
TokenScopeWrite = "write"
|
||||
|
||||
// lastUsedWriteInterval 限制回写频率:单连接 SQLite 上每个请求都写一次不划算,
|
||||
// 同一分钟内重复使用不再写。
|
||||
lastUsedWriteInterval = time.Minute
|
||||
|
||||
maxTokenNameLength = 64
|
||||
)
|
||||
|
||||
type APIToken struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
LastUsedAt string `json:"last_used_at,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// CreatedAPIToken 只在创建响应里出现:Token 是明文,之后无法再取回。
|
||||
type CreatedAPIToken struct {
|
||||
APIToken
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
func (s *Service) ListAPITokens(ctx context.Context) ([]APIToken, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, name, scope, COALESCE(expires_at, ''), COALESCE(last_used_at, ''), created_at
|
||||
FROM api_tokens ORDER BY id DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
tokens := make([]APIToken, 0, 8)
|
||||
for rows.Next() {
|
||||
var token APIToken
|
||||
if err := rows.Scan(&token.ID, &token.Name, &token.Scope, &token.ExpiresAt, &token.LastUsedAt, &token.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokens = append(tokens, token)
|
||||
}
|
||||
return tokens, rows.Err()
|
||||
}
|
||||
|
||||
// CreateAPIToken 新建令牌并返回一次明文;expiresAt 为零值表示永不过期。
|
||||
func (s *Service) CreateAPIToken(ctx context.Context, name, scope string, expiresAt time.Time) (CreatedAPIToken, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return CreatedAPIToken{}, fmt.Errorf("请填写令牌名称")
|
||||
}
|
||||
if len([]rune(name)) > maxTokenNameLength {
|
||||
return CreatedAPIToken{}, fmt.Errorf("令牌名称最长 %d 个字", maxTokenNameLength)
|
||||
}
|
||||
if scope != TokenScopeRead && scope != TokenScopeWrite {
|
||||
return CreatedAPIToken{}, fmt.Errorf("无效的权限范围")
|
||||
}
|
||||
|
||||
plain, err := randomToken(32)
|
||||
if err != nil {
|
||||
return CreatedAPIToken{}, err
|
||||
}
|
||||
expires := ""
|
||||
if !expiresAt.IsZero() {
|
||||
expires = expiresAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO api_tokens(name, token_hash, scope, expires_at) VALUES (?, ?, ?, ?)`,
|
||||
name, hashToken(plain), scope, expires)
|
||||
if err != nil {
|
||||
return CreatedAPIToken{}, err
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return CreatedAPIToken{}, err
|
||||
}
|
||||
token, err := s.apiTokenByID(ctx, id)
|
||||
if err != nil {
|
||||
return CreatedAPIToken{}, err
|
||||
}
|
||||
return CreatedAPIToken{APIToken: token, Token: plain}, nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteAPIToken(ctx context.Context, id int64) error {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM api_tokens WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return fmt.Errorf("令牌不存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateAPIToken 校验明文令牌并返回权限范围;校验通过后限频回写最后使用时间。
|
||||
func (s *Service) ValidateAPIToken(ctx context.Context, plain string) (string, error) {
|
||||
plain = strings.TrimSpace(plain)
|
||||
if plain == "" {
|
||||
return "", ErrUnauthorized
|
||||
}
|
||||
|
||||
var (
|
||||
id int64
|
||||
scope string
|
||||
expiresAt string
|
||||
)
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, scope, COALESCE(expires_at, '') FROM api_tokens WHERE token_hash = ?`, hashToken(plain)).
|
||||
Scan(&id, &scope, &expiresAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ErrUnauthorized
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if expiresAt != "" {
|
||||
expires, err := time.Parse(time.RFC3339, expiresAt)
|
||||
if err != nil || time.Now().After(expires) {
|
||||
return "", ErrUnauthorized
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
cutoff := now.Add(-lastUsedWriteInterval).Format(time.RFC3339)
|
||||
// 回写失败不影响本次调用:last_used_at 只是审计信息
|
||||
_, _ = s.db.ExecContext(ctx,
|
||||
`UPDATE api_tokens SET last_used_at = ? WHERE id = ? AND (last_used_at IS NULL OR last_used_at < ?)`,
|
||||
now.Format(time.RFC3339), id, cutoff)
|
||||
return scope, nil
|
||||
}
|
||||
|
||||
func (s *Service) apiTokenByID(ctx context.Context, id int64) (APIToken, error) {
|
||||
var token APIToken
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, name, scope, COALESCE(expires_at, ''), COALESCE(last_used_at, ''), created_at
|
||||
FROM api_tokens WHERE id = ?`, id).
|
||||
Scan(&token.ID, &token.Name, &token.Scope, &token.ExpiresAt, &token.LastUsedAt, &token.CreatedAt)
|
||||
return token, err
|
||||
}
|
||||
|
||||
func hashToken(plain string) string {
|
||||
sum := sha256.Sum256([]byte(plain))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/db"
|
||||
)
|
||||
|
||||
func newTestService(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
conn, err := db.Open(filepath.Join(t.TempDir(), "fonu.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return New(conn)
|
||||
}
|
||||
|
||||
func TestAPITokenLifecycle(t *testing.T) {
|
||||
svc := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
created, err := svc.CreateAPIToken(ctx, "homeassistant", TokenScopeRead, time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if created.Token == "" || created.Scope != TokenScopeRead || created.ID == 0 {
|
||||
t.Fatalf("unexpected created token: %+v", created)
|
||||
}
|
||||
|
||||
// 库里只存 SHA-256:明文不落盘
|
||||
var stored string
|
||||
if err := svc.db.QueryRowContext(ctx, `SELECT token_hash FROM api_tokens WHERE id = ?`, created.ID).Scan(&stored); err != nil {
|
||||
t.Fatalf("read hash: %v", err)
|
||||
}
|
||||
if stored == created.Token || len(stored) != 64 {
|
||||
t.Fatalf("令牌应以 SHA-256 存储,实际 %q", stored)
|
||||
}
|
||||
|
||||
scope, err := svc.ValidateAPIToken(ctx, created.Token)
|
||||
if err != nil || scope != TokenScopeRead {
|
||||
t.Fatalf("validate: scope=%q err=%v", scope, err)
|
||||
}
|
||||
|
||||
var lastUsed string
|
||||
if err := svc.db.QueryRowContext(ctx, `SELECT COALESCE(last_used_at, '') FROM api_tokens WHERE id = ?`, created.ID).Scan(&lastUsed); err != nil {
|
||||
t.Fatalf("read last_used_at: %v", err)
|
||||
}
|
||||
if lastUsed == "" {
|
||||
t.Fatal("校验通过后应回写 last_used_at")
|
||||
}
|
||||
|
||||
if _, err := svc.ValidateAPIToken(ctx, "not-a-real-token"); err != ErrUnauthorized {
|
||||
t.Fatalf("未知令牌应拒绝,实际 %v", err)
|
||||
}
|
||||
if err := svc.DeleteAPIToken(ctx, created.ID); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if _, err := svc.ValidateAPIToken(ctx, created.Token); err != ErrUnauthorized {
|
||||
t.Fatalf("删除后令牌应失效,实际 %v", err)
|
||||
}
|
||||
if err := svc.DeleteAPIToken(ctx, created.ID); err == nil {
|
||||
t.Fatal("重复删除应报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPITokenExpiry(t *testing.T) {
|
||||
svc := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
expired, err := svc.CreateAPIToken(ctx, "old-script", TokenScopeWrite, time.Now().Add(-time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if _, err := svc.ValidateAPIToken(ctx, expired.Token); err != ErrUnauthorized {
|
||||
t.Fatalf("过期令牌应拒绝,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPITokenRejectsBadInput(t *testing.T) {
|
||||
svc := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := svc.CreateAPIToken(ctx, " ", TokenScopeRead, time.Time{}); err == nil {
|
||||
t.Fatal("空名称应报错")
|
||||
}
|
||||
if _, err := svc.CreateAPIToken(ctx, "bad-scope", "admin", time.Time{}); err == nil {
|
||||
t.Fatal("未知权限范围应报错")
|
||||
}
|
||||
if _, err := svc.ValidateAPIToken(ctx, " "); err != ErrUnauthorized {
|
||||
t.Fatalf("空令牌应拒绝,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAPITokensReturnsNewestFirst(t *testing.T) {
|
||||
svc := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := svc.CreateAPIToken(ctx, "first", TokenScopeRead, time.Time{}); err != nil {
|
||||
t.Fatalf("create first: %v", err)
|
||||
}
|
||||
if _, err := svc.CreateAPIToken(ctx, "second", TokenScopeWrite, time.Time{}); err != nil {
|
||||
t.Fatalf("create second: %v", err)
|
||||
}
|
||||
tokens, err := svc.ListAPITokens(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(tokens) != 2 || tokens[0].Name != "second" {
|
||||
t.Fatalf("应按 id 倒序返回:%+v", tokens)
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,9 @@ func (c *AgentClient) Status(ctx context.Context) (map[string]any, error) {
|
||||
// agentMinVersionInsights 是 P1 三个只读端点(metrics / logs / frps-config)要求的最低 agent 版本
|
||||
const agentMinVersionInsights = "0.11.0"
|
||||
|
||||
// agentMinVersionRouteVersions 是域名 vhost 版本列表 / 对比 / 回滚端点要求的最低 agent 版本
|
||||
const agentMinVersionRouteVersions = "0.12.2"
|
||||
|
||||
// ErrAgentEndpointMissing 表示 VPS 上的 agent 版本过旧、没有该端点(HTTP 404/405)
|
||||
var ErrAgentEndpointMissing = errors.New("VPS 上的 agent 版本过低,不支持该接口")
|
||||
|
||||
@@ -93,11 +96,11 @@ func (e *statusError) Error() string {
|
||||
return fmt.Sprintf("agent 返回 HTTP %d: %s", e.code, e.body)
|
||||
}
|
||||
|
||||
// wrapEndpointMissing 把「端点不存在」统一成带升级提示的错误,其它错误原样返回
|
||||
func wrapEndpointMissing(err error) error {
|
||||
// wrapEndpointMissing 把「端点不存在」统一成带升级提示的错误(按端点给出最低版本),其它错误原样返回
|
||||
func wrapEndpointMissing(err error, minVersion string) error {
|
||||
var statusErr *statusError
|
||||
if errors.As(err, &statusErr) && (statusErr.code == http.StatusNotFound || statusErr.code == http.StatusMethodNotAllowed) {
|
||||
return fmt.Errorf("%w(需 ≥ %s),请在服务端详情里升级 Agent", ErrAgentEndpointMissing, agentMinVersionInsights)
|
||||
return fmt.Errorf("%w(需 ≥ %s),请在服务端详情里升级 Agent", ErrAgentEndpointMissing, minVersion)
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -106,7 +109,7 @@ func wrapEndpointMissing(err error) error {
|
||||
func (c *AgentClient) Metrics(ctx context.Context) (map[string]any, error) {
|
||||
var result map[string]any
|
||||
if err := c.request(ctx, http.MethodGet, "/api/v1/metrics", nil, &result); err != nil {
|
||||
return nil, wrapEndpointMissing(err)
|
||||
return nil, wrapEndpointMissing(err, agentMinVersionInsights)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -118,7 +121,7 @@ func (c *AgentClient) NginxLogs(ctx context.Context, kind string, lines int) (ma
|
||||
query.Set("lines", strconv.Itoa(lines))
|
||||
var result map[string]any
|
||||
if err := c.request(ctx, http.MethodGet, "/api/v1/logs/nginx?"+query.Encode(), nil, &result); err != nil {
|
||||
return nil, wrapEndpointMissing(err)
|
||||
return nil, wrapEndpointMissing(err, agentMinVersionInsights)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -127,7 +130,7 @@ func (c *AgentClient) NginxLogs(ctx context.Context, kind string, lines int) (ma
|
||||
func (c *AgentClient) Certs(ctx context.Context) (map[string]any, error) {
|
||||
var result map[string]any
|
||||
if err := c.request(ctx, http.MethodGet, "/api/v1/certs", nil, &result); err != nil {
|
||||
return nil, wrapEndpointMissing(err)
|
||||
return nil, wrapEndpointMissing(err, agentMinVersionInsights)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -136,7 +139,7 @@ func (c *AgentClient) Certs(ctx context.Context) (map[string]any, error) {
|
||||
func (c *AgentClient) NginxReload(ctx context.Context) (map[string]any, error) {
|
||||
var result map[string]any
|
||||
if err := c.request(ctx, http.MethodPost, "/api/v1/nginx/reload", nil, &result); err != nil {
|
||||
return nil, wrapEndpointMissing(err)
|
||||
return nil, wrapEndpointMissing(err, agentMinVersionInsights)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -145,7 +148,7 @@ func (c *AgentClient) NginxReload(ctx context.Context) (map[string]any, error) {
|
||||
func (c *AgentClient) FRPSConfig(ctx context.Context) (map[string]any, error) {
|
||||
var result map[string]any
|
||||
if err := c.request(ctx, http.MethodGet, "/api/v1/frps/config", nil, &result); err != nil {
|
||||
return nil, wrapEndpointMissing(err)
|
||||
return nil, wrapEndpointMissing(err, agentMinVersionInsights)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -258,3 +261,32 @@ func (c *AgentClient) PushCertificate(ctx context.Context, domain, fullchain, pr
|
||||
"domain": domain, "fullchain_pem": fullchain, "key_pem": privateKey,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// RouteVersions 返回域名当前 vhost 内容与历史版本列表(需 agent ≥ 0.12.2)。
|
||||
func (c *AgentClient) RouteVersions(ctx context.Context, domain string) (map[string]any, error) {
|
||||
var result map[string]any
|
||||
if err := c.request(ctx, http.MethodGet, "/api/v1/routes/"+domain+"/versions", nil, &result); err != nil {
|
||||
return nil, wrapEndpointMissing(err, agentMinVersionRouteVersions)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// RouteVersion 读取某个历史版本的内容,供与当前 vhost 做差异对比。
|
||||
func (c *AgentClient) RouteVersion(ctx context.Context, domain, name string) (map[string]any, error) {
|
||||
var result map[string]any
|
||||
path := "/api/v1/routes/" + domain + "/versions/" + url.PathEscape(name)
|
||||
if err := c.request(ctx, http.MethodGet, path, nil, &result); err != nil {
|
||||
return nil, wrapEndpointMissing(err, agentMinVersionRouteVersions)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// RollbackRouteVersion 让 VPS 上的 vhost 回退到指定版本。
|
||||
func (c *AgentClient) RollbackRouteVersion(ctx context.Context, domain, name string) (map[string]any, error) {
|
||||
var result map[string]any
|
||||
path := "/api/v1/routes/" + domain + "/versions/" + url.PathEscape(name) + "/rollback"
|
||||
if err := c.request(ctx, http.MethodPost, path, nil, &result); err != nil {
|
||||
return nil, wrapEndpointMissing(err, agentMinVersionRouteVersions)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -5,8 +5,15 @@
|
||||
package fsutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WriteFileAtomic 先写同目录的临时文件再 rename:读者要么看到旧内容、要么看到新内容。
|
||||
@@ -53,3 +60,115 @@ func Backup(path string) error {
|
||||
}
|
||||
return WriteFileAtomic(path+".bak", data, 0o600)
|
||||
}
|
||||
|
||||
// VersionsDir 返回某文件的版本目录(<path>.versions),供列表 / 回滚读取。
|
||||
func VersionsDir(path string) string {
|
||||
return path + ".versions"
|
||||
}
|
||||
|
||||
// BackupVersioned 把现有文件留存为 <path>.versions/<时间戳>-<随机>.conf,并只保留最近 keep 份。
|
||||
// 文件名前缀是 UTC 时间戳(同一秒内用随机后缀避开冲突),因此目录内按文件名倒序就是版本新旧顺序。
|
||||
// 文件不存在视为无需备份,返回空串;keep <= 0 表示不剪枝。
|
||||
func BackupVersioned(path string, keep int) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir := VersionsDir(path)
|
||||
// 内容与最新版本一致时不重复记录:Apply 每次保存都会调用,去重后保留期才对应「真实的 N 次变更」
|
||||
if versions, err := ListVersions(path); err == nil && len(versions) > 0 {
|
||||
if newest, err := ReadVersion(path, versions[0].Name); err == nil && bytes.Equal(newest, data) {
|
||||
return filepath.Join(dir, versions[0].Name), nil
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
name := time.Now().UTC().Format("20060102T150405.000") + "-" + randSuffix() + filepath.Ext(path)
|
||||
target := filepath.Join(dir, name)
|
||||
if err := WriteFileAtomic(target, data, 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if keep > 0 {
|
||||
pruneVersions(dir, keep)
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// VersionEntry 描述一个已留存的版本(名称前缀是 UTC 时间戳,因此按名称倒序就是新旧顺序)。
|
||||
type VersionEntry struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ListVersions 按新的在前列出版本;版本目录不存在时返回空列表(首次备份前是正常状态)。
|
||||
func ListVersions(path string) ([]VersionEntry, error) {
|
||||
entries, err := os.ReadDir(VersionsDir(path))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
out := make([]VersionEntry, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, VersionEntry{Name: entry.Name(), Size: info.Size(), CreatedAt: info.ModTime().UTC()})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name > out[j].Name })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ReadVersion 读取指定版本的内容。版本名虽然来自 ListVersions,这里仍校验一次:
|
||||
// 带路径分隔符 / 上跳的名称直接拒绍,避免接口被用来读版本目录以外的文件。
|
||||
func ReadVersion(path, name string) ([]byte, error) {
|
||||
if name == "" || name != filepath.Base(name) || name == "." || name == ".." {
|
||||
return nil, fmt.Errorf("版本名无效")
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(VersionsDir(path), name))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("版本不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// pruneVersions 只保留最新的 keep 份(按文件名倒序,前缀是时间戳)。
|
||||
// 剪枝失败不影响本次备份结果,因此只忽略错误。
|
||||
func pruneVersions(dir string, keep int) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
sort.Sort(sort.Reverse(sort.StringSlice(names)))
|
||||
for _, name := range names[min(keep, len(names)):] {
|
||||
_ = os.Remove(filepath.Join(dir, name))
|
||||
}
|
||||
}
|
||||
|
||||
func randSuffix() string {
|
||||
var buf [2]byte
|
||||
if _, err := rand.Read(buf[:]); err != nil {
|
||||
return "0000"
|
||||
}
|
||||
return hex.EncodeToString(buf[:])
|
||||
}
|
||||
|
||||
@@ -69,3 +69,132 @@ func TestBackupCopiesContent(t *testing.T) {
|
||||
t.Fatalf("备份内容不符:%q", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupVersionedStoresContent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "nginx.conf")
|
||||
if err := os.WriteFile(path, []byte("worker_processes 1;"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
version, err := BackupVersioned(path, 5)
|
||||
if err != nil || version == "" {
|
||||
t.Fatalf("应返回版本路径:%q %v", version, err)
|
||||
}
|
||||
if !strings.Contains(version, VersionsDir(path)) {
|
||||
t.Fatalf("版本应落在版本目录内:%s", version)
|
||||
}
|
||||
data, err := os.ReadFile(version)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != "worker_processes 1;" {
|
||||
t.Fatalf("版本内容不符:%q", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupVersionedPrunesToKeep(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "nginx.conf")
|
||||
// 每次写入不同内容:内容相同会被去重,剪枝也就无从检验
|
||||
for _, content := range []string{"v1", "v2", "v3", "v4", "v5"} {
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := BackupVersioned(path, 2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
entries, err := os.ReadDir(VersionsDir(path))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("应只保留 2 份版本,实际 %d", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupVersionedSkipsMissingFile(t *testing.T) {
|
||||
version, err := BackupVersioned(filepath.Join(t.TempDir(), "nope.conf"), 5)
|
||||
if err != nil || version != "" {
|
||||
t.Fatalf("文件不存在时应返回空路径且无错误:%q %v", version, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListVersionsNewestFirst(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "nginx.conf")
|
||||
if err := os.WriteFile(path, []byte("v1"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := BackupVersioned(path, 5); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("v2"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := BackupVersioned(path, 5); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
versions, err := ListVersions(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(versions) != 2 || versions[0].Name <= versions[1].Name {
|
||||
t.Fatalf("版本应按名称倒序(新的在前)列出:%v", versions)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, version := range versions {
|
||||
data, err := ReadVersion(path, version.Name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if version.Size != int64(len(data)) {
|
||||
t.Fatalf("版本大小不符:%d vs %d", version.Size, len(data))
|
||||
}
|
||||
seen[string(data)] = true
|
||||
}
|
||||
if !seen["v1"] || !seen["v2"] {
|
||||
t.Fatalf("两个版本的内容都应在:%v", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupVersionedSkipsIdenticalContent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "nginx.conf")
|
||||
if err := os.WriteFile(path, []byte("same"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := BackupVersioned(path, 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := BackupVersioned(path, 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first != second {
|
||||
t.Fatalf("内容未变时不应新增版本:%q vs %q", first, second)
|
||||
}
|
||||
versions, err := ListVersions(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(versions) != 1 {
|
||||
t.Fatalf("内容未变时应只有 1 个版本,实际 %d", len(versions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadVersionRejectsTraversal(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "nginx.conf")
|
||||
if _, err := ReadVersion(path, "../nginx.conf"); err == nil {
|
||||
t.Fatal("带路径上跳的版本名应被拒绝")
|
||||
}
|
||||
if _, err := ReadVersion(path, "missing.conf"); err == nil {
|
||||
t.Fatal("不存在的版本应报错")
|
||||
}
|
||||
versions, err := ListVersions(path)
|
||||
if err != nil || len(versions) != 0 {
|
||||
t.Fatalf("版本目录不存在时应返回空列表且无错误:%v %v", versions, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
// Package monitor 周期巡检「资源占用」与「隧道连通性」这两类必须持续观察才会出现的问题。
|
||||
// 事件型通知(DDNS 失败、证书到期)由各自模块在发生时直接发出,不需要巡检。
|
||||
//
|
||||
// 判定在这里,通知只走 notify.Service:开关、阈值、冷却都读设置项,
|
||||
// 所以本包不需要知道通知通道的任何细节。
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/notify"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
"github.com/fonu/fonu/internal/sysinfo"
|
||||
)
|
||||
|
||||
// RouteStatus 是巡检关心的隧道状态;由调用方注入(避免 monitor 依赖 frp 包)。
|
||||
type RouteStatus struct {
|
||||
Domain string
|
||||
TunnelOK bool
|
||||
Detail string
|
||||
}
|
||||
|
||||
// Checker 每轮都重新读设置项:改阈值或开关不需要重启。
|
||||
type Checker struct {
|
||||
settings *settings.Store
|
||||
notify *notify.Service
|
||||
logger *slog.Logger
|
||||
routes func(ctx context.Context) ([]RouteStatus, error)
|
||||
|
||||
mu sync.Mutex
|
||||
cpuPrev sysinfo.CPUSample
|
||||
resourceAlerts map[string]time.Time
|
||||
tunnelFailures map[string]int
|
||||
}
|
||||
|
||||
func New(settingsStore *settings.Store, notifySvc *notify.Service, logger *slog.Logger, routes func(context.Context) ([]RouteStatus, error)) *Checker {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Checker{
|
||||
settings: settingsStore,
|
||||
notify: notifySvc,
|
||||
logger: logger,
|
||||
routes: routes,
|
||||
resourceAlerts: map[string]time.Time{},
|
||||
tunnelFailures: map[string]int{},
|
||||
}
|
||||
}
|
||||
|
||||
// Run 跑一轮巡检。scheduler 已对任务包了 recover,这里不重复兜底。
|
||||
func (c *Checker) Run(ctx context.Context) {
|
||||
if c == nil || c.settings == nil {
|
||||
return
|
||||
}
|
||||
c.checkResources(ctx)
|
||||
c.checkTunnels(ctx)
|
||||
}
|
||||
|
||||
func (c *Checker) checkResources(ctx context.Context) {
|
||||
// 开关关闭时连采集都省掉(关掉的功能不该有后台开销)
|
||||
if enabled, _ := c.settings.GetBool(ctx, settings.KeyNotifyOnResourceThreshold); !enabled {
|
||||
return
|
||||
}
|
||||
cpuLimit, _ := c.settings.GetInt(ctx, settings.KeyNotifyCPUThreshold)
|
||||
memoryLimit, _ := c.settings.GetInt(ctx, settings.KeyNotifyMemoryThreshold)
|
||||
diskLimit, _ := c.settings.GetInt(ctx, settings.KeyNotifyDiskThreshold)
|
||||
|
||||
c.mu.Lock()
|
||||
stats, sample := sysinfo.Read(c.cpuPrev)
|
||||
c.cpuPrev = sample
|
||||
c.mu.Unlock()
|
||||
|
||||
limits := []struct {
|
||||
metric string
|
||||
label string
|
||||
limit int
|
||||
value float64
|
||||
}{
|
||||
{"cpu", "CPU", cpuLimit, stats.CPUPercent},
|
||||
{"memory", "内存", memoryLimit, percent(stats.MemUsedBytes, stats.MemTotalBytes)},
|
||||
{"disk", "磁盘", diskLimit, percent(stats.DiskUsedBytes, stats.DiskTotalBytes)},
|
||||
}
|
||||
now := time.Now()
|
||||
for _, item := range limits {
|
||||
// 阈值 <= 0 表示这一项不告警(CPU 首次采样时占用率为 0,也不会误报)
|
||||
if item.limit <= 0 {
|
||||
continue
|
||||
}
|
||||
if item.value < float64(item.limit) {
|
||||
// 回落到阈值以下就清冷却:再次超标可以立刻提醒
|
||||
c.mu.Lock()
|
||||
delete(c.resourceAlerts, item.metric)
|
||||
c.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
if !c.allowResourceAlert(item.metric, now) {
|
||||
continue
|
||||
}
|
||||
c.notify.Alert(ctx, notify.EventResourceThreshold, "资源占用过高",
|
||||
fmt.Sprintf("%s 使用率 %.1f%%,已超过阈值 %d%%", item.label, item.value, item.limit))
|
||||
}
|
||||
}
|
||||
|
||||
// allowResourceAlert 按静默期限流:持续高占用每 DefaultResourceAlertCooldown 提醒一次。
|
||||
func (c *Checker) allowResourceAlert(metric string, now time.Time) bool {
|
||||
cooldown := time.Duration(notify.DefaultResourceAlertCooldown) * time.Second
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if last, ok := c.resourceAlerts[metric]; ok && now.Sub(last) < cooldown {
|
||||
return false
|
||||
}
|
||||
c.resourceAlerts[metric] = now
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *Checker) checkTunnels(ctx context.Context) {
|
||||
if c.routes == nil {
|
||||
return
|
||||
}
|
||||
if enabled, _ := c.settings.GetBool(ctx, settings.KeyNotifyOnTunnelDown); !enabled {
|
||||
return
|
||||
}
|
||||
limit, _ := c.settings.GetInt(ctx, settings.KeyNotifyTunnelFailThreshold)
|
||||
if limit <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
routes, err := c.routes(ctx)
|
||||
if err != nil {
|
||||
c.logger.Warn("巡检隧道状态失败", "module", "MONITOR", "error", err.Error())
|
||||
return
|
||||
}
|
||||
for _, route := range routes {
|
||||
if strings.TrimSpace(route.Domain) == "" {
|
||||
continue
|
||||
}
|
||||
c.mu.Lock()
|
||||
failures, event := tunnelTransition(c.tunnelFailures[route.Domain], route.TunnelOK, limit)
|
||||
c.tunnelFailures[route.Domain] = failures
|
||||
c.mu.Unlock()
|
||||
|
||||
switch event {
|
||||
case tunnelEventDown:
|
||||
c.notify.Alert(ctx, notify.EventTunnelDown, "隧道连续不通",
|
||||
fmt.Sprintf("%s 连续 %d 次巡检未在 frps 上注册:%s", route.Domain, limit, detailOr(route.Detail)))
|
||||
case tunnelEventRecovered:
|
||||
c.notify.Alert(ctx, notify.EventTunnelDown, "隧道已恢复",
|
||||
fmt.Sprintf("%s 已重新在 frps 上注册", route.Domain))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type tunnelEvent string
|
||||
|
||||
const (
|
||||
tunnelEventNone tunnelEvent = ""
|
||||
tunnelEventDown tunnelEvent = "down"
|
||||
tunnelEventRecovered tunnelEvent = "recovered"
|
||||
)
|
||||
|
||||
// tunnelTransition 推进某个域名的连续失败计数,返回新计数与本轮要发的事件。
|
||||
// 只在「刚好达到阈值」与「从失败中恢复」两个时刻发,避免每轮巡检都刷通知。
|
||||
func tunnelTransition(previous int, tunnelOK bool, limit int) (int, tunnelEvent) {
|
||||
if tunnelOK {
|
||||
if previous >= limit {
|
||||
return 0, tunnelEventRecovered
|
||||
}
|
||||
return 0, tunnelEventNone
|
||||
}
|
||||
next := previous + 1
|
||||
if next == limit {
|
||||
return next, tunnelEventDown
|
||||
}
|
||||
return next, tunnelEventNone
|
||||
}
|
||||
|
||||
func percent(used, total uint64) float64 {
|
||||
if total == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(used) / float64(total) * 100
|
||||
}
|
||||
|
||||
func detailOr(detail string) string {
|
||||
if strings.TrimSpace(detail) == "" {
|
||||
return "未返回具体原因"
|
||||
}
|
||||
return detail
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package monitor
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTunnelTransitionAlertsOncePerOutage(t *testing.T) {
|
||||
const limit = 3
|
||||
failures := 0
|
||||
var events []tunnelEvent
|
||||
|
||||
// 连续三轮不通:只在第 3 轮(刚好达到阈值)发一次
|
||||
for i := 0; i < 5; i++ {
|
||||
var event tunnelEvent
|
||||
failures, event = tunnelTransition(failures, false, limit)
|
||||
if event != tunnelEventNone {
|
||||
events = append(events, event)
|
||||
}
|
||||
}
|
||||
if len(events) != 1 || events[0] != tunnelEventDown {
|
||||
t.Fatalf("连续不通应只告警一次,实际 %v", events)
|
||||
}
|
||||
if failures != 5 {
|
||||
t.Fatalf("失败计数应继续累加,实际 %d", failures)
|
||||
}
|
||||
|
||||
// 恢复:发一次恢复通知并清零
|
||||
failures, event := tunnelTransition(failures, true, limit)
|
||||
if event != tunnelEventRecovered || failures != 0 {
|
||||
t.Fatalf("恢复应通知一次并清零:event=%v failures=%d", event, failures)
|
||||
}
|
||||
|
||||
// 恢复后再次不通:还能再报一次
|
||||
failures, event = tunnelTransition(failures, false, limit)
|
||||
if event != tunnelEventNone || failures != 1 {
|
||||
t.Fatalf("恢复后重新计数:event=%v failures=%d", event, failures)
|
||||
}
|
||||
failures, event = tunnelTransition(failures, false, limit)
|
||||
if event != tunnelEventNone {
|
||||
t.Fatalf("未达阈值不应告警:%v", event)
|
||||
}
|
||||
if _, event = tunnelTransition(failures, false, limit); event != tunnelEventDown {
|
||||
t.Fatalf("第二次中断达到阈值应再告警一次:%v", event)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelTransitionBelowThresholdIsSilent(t *testing.T) {
|
||||
// 单次抖动(未达阈值)不打扰用户,也不算作恢复
|
||||
if failures, event := tunnelTransition(0, true, 3); failures != 0 || event != tunnelEventNone {
|
||||
t.Fatalf("健康状态不应产生事件:%d %v", failures, event)
|
||||
}
|
||||
if failures, event := tunnelTransition(1, true, 3); failures != 0 || event != tunnelEventNone {
|
||||
t.Fatalf("未达阈值的恢复不应通知:%d %v", failures, event)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPercent(t *testing.T) {
|
||||
if got := percent(0, 0); got != 0 {
|
||||
t.Fatalf("总数为 0 时应返回 0,实际 %v", got)
|
||||
}
|
||||
if got := percent(50, 200); got != 25 {
|
||||
t.Fatalf("期望 25,实际 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailOrFallsBack(t *testing.T) {
|
||||
if got := detailOr(" "); got != "未返回具体原因" {
|
||||
t.Fatalf("空详情应给出兜底文案,实际 %q", got)
|
||||
}
|
||||
if got := detailOr("dashboard 超时"); got != "dashboard 超时" {
|
||||
t.Fatalf("非空详情应原样返回,实际 %q", got)
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -131,17 +131,27 @@ func (m *Manager) Apply(ctx context.Context, rules []proxy.Rule, certs []CertSou
|
||||
}
|
||||
|
||||
currentPath := m.cfg.NginxConfigPath()
|
||||
// 覆盖前留 .bak(fsutil.Backup 对文件不存在视为无需备份)
|
||||
// 覆盖前留退路:.bak 保留最近一次(兼容既有读取方),.versions/ 保留最近 10 份(供版本对比与回滚)
|
||||
if err := fsutil.Backup(currentPath); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
if _, err := fsutil.BackupVersioned(currentPath, maxConfigVersions); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpPath, currentPath); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
|
||||
return m.activate(ctx)
|
||||
}
|
||||
|
||||
// activate 让磁盘上的主配置在 nginx 里生效:未运行则启动,运行中优先 reload、失败再重启。
|
||||
// Apply 与回滚共用这段,保证「写回磁盘」与「让它生效」在两条路径上行为一致。
|
||||
func (m *Manager) activate(ctx context.Context) (ApplyResult, error) {
|
||||
if !m.available() {
|
||||
return ApplyResult{Message: "Nginx 未安装,配置已保存"}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package nginx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/fsutil"
|
||||
)
|
||||
|
||||
// maxConfigVersions 主配置保留的版本份数。Apply 每次保存都会留一份,fsutil 会跳过内容相同的版本,
|
||||
// 因此这里对应的是最近 10 次「配置真的变了」的改动。
|
||||
const maxConfigVersions = 10
|
||||
|
||||
// ConfigVersion 是主配置(nginx.conf)的一个历史版本。
|
||||
type ConfigVersion struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ReadConfig 读取当前主配置内容,供前端与历史版本做差异对比。
|
||||
func (m *Manager) ReadConfig() (string, error) {
|
||||
data, err := os.ReadFile(m.cfg.NginxConfigPath())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// ListConfigVersions 列出主配置的历史版本(新的在前);从未写过配置时返回空列表。
|
||||
func (m *Manager) ListConfigVersions() ([]ConfigVersion, error) {
|
||||
versions, err := fsutil.ListVersions(m.cfg.NginxConfigPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ConfigVersion, 0, len(versions))
|
||||
for _, version := range versions {
|
||||
out = append(out, ConfigVersion{Name: version.Name, Size: version.Size, CreatedAt: version.CreatedAt})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ReadConfigVersion 读取某个历史版本的内容。
|
||||
func (m *Manager) ReadConfigVersion(name string) (string, error) {
|
||||
data, err := fsutil.ReadVersion(m.cfg.NginxConfigPath(), name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// RollbackConfigVersion 把主配置回退到指定版本。
|
||||
//
|
||||
// 与 Apply 同构:先语法校验、再 nginx -t 校验,两关都过才写回并重载;校验失败保持当前配置不动。
|
||||
// 写回前把当前内容也留成一个版本,所以回退本身同样可以再回退。
|
||||
func (m *Manager) RollbackConfigVersion(ctx context.Context, name string) (ApplyResult, error) {
|
||||
if name == "" {
|
||||
return ApplyResult{}, fmt.Errorf("请选择要回滚的版本")
|
||||
}
|
||||
content, err := fsutil.ReadVersion(m.cfg.NginxConfigPath(), name)
|
||||
if err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
if err := m.EnsureDirs(); err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
if err := ValidateConfigSyntax(string(content)); err != nil {
|
||||
return ApplyResult{}, fmt.Errorf("该版本无法通过语法校验,已保持当前配置:%s", err)
|
||||
}
|
||||
if m.available() {
|
||||
tmpPath := m.cfg.NginxConfigPath() + ".rollback.tmp"
|
||||
if err := fsutil.WriteFileAtomic(tmpPath, content, 0o644); err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
validateErr := m.validate(ctx, tmpPath)
|
||||
_ = os.Remove(tmpPath)
|
||||
if validateErr != nil {
|
||||
return ApplyResult{}, fmt.Errorf("该版本无法通过 nginx 校验,已保持当前配置:%s", validateErr)
|
||||
}
|
||||
}
|
||||
|
||||
currentPath := m.cfg.NginxConfigPath()
|
||||
if err := fsutil.Backup(currentPath); err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
if _, err := fsutil.BackupVersioned(currentPath, maxConfigVersions); err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
if err := fsutil.WriteFileAtomic(currentPath, content, 0o644); err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
|
||||
result, err := m.activate(ctx)
|
||||
if err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
result.Message = "已回滚到所选版本," + result.Message
|
||||
return result, nil
|
||||
}
|
||||
+83
-44
@@ -9,16 +9,29 @@ const (
|
||||
EventIPFrequentAccess = "ip_frequent_access"
|
||||
EventLoginFailure = "login_failure"
|
||||
EventNginxReloadFailure = "nginx_reload_failure"
|
||||
EventResourceThreshold = "resource_threshold"
|
||||
EventTunnelDown = "tunnel_down"
|
||||
|
||||
MaskedSecret = "********"
|
||||
|
||||
DefaultBarkServer = "https://api.day.app"
|
||||
DefaultIPFrequentThreshold = 100
|
||||
DefaultIPFrequentWindowSec = 60
|
||||
DefaultIPFrequentAlertCooldown = 15 * 60 // seconds
|
||||
DefaultLoginFailureThreshold = 5
|
||||
DefaultLoginFailureWindowSec = 300
|
||||
DefaultBarkServer = "https://api.day.app"
|
||||
DefaultNtfyServer = "https://ntfy.sh"
|
||||
DefaultWecomServer = "https://qyapi.weixin.qq.com"
|
||||
DefaultDingtalkServer = "https://oapi.dingtalk.com"
|
||||
DefaultFeishuServer = "https://open.feishu.cn"
|
||||
DefaultIPFrequentThreshold = 100
|
||||
DefaultIPFrequentWindowSec = 60
|
||||
DefaultIPFrequentAlertCooldown = 15 * 60 // seconds
|
||||
DefaultLoginFailureThreshold = 5
|
||||
DefaultLoginFailureWindowSec = 300
|
||||
DefaultLoginFailureAlertCooldown = 15 * 60 // seconds
|
||||
DefaultCPUThreshold = 90
|
||||
DefaultMemoryThreshold = 90
|
||||
DefaultDiskThreshold = 85
|
||||
DefaultTunnelFailThreshold = 3
|
||||
// DefaultResourceAlertCooldown 是资源类告警的静默期:持续高占用时每半小时提醒一次,
|
||||
// 期间恢复正常会清掉冷却,再次超标可立即再报。
|
||||
DefaultResourceAlertCooldown = 30 * 60 // seconds
|
||||
)
|
||||
|
||||
type NotifyType string
|
||||
@@ -32,12 +45,26 @@ const (
|
||||
type WebhookProvider string
|
||||
|
||||
const (
|
||||
WebhookBark WebhookProvider = "bark"
|
||||
WebhookNtfy WebhookProvider = "ntfy"
|
||||
WebhookGotify WebhookProvider = "gotify"
|
||||
WebhookCustom WebhookProvider = "custom"
|
||||
WebhookBark WebhookProvider = "bark"
|
||||
WebhookNtfy WebhookProvider = "ntfy"
|
||||
WebhookGotify WebhookProvider = "gotify"
|
||||
WebhookWecom WebhookProvider = "wecom"
|
||||
WebhookDingtalk WebhookProvider = "dingtalk"
|
||||
WebhookFeishu WebhookProvider = "feishu"
|
||||
WebhookCustom WebhookProvider = "custom"
|
||||
)
|
||||
|
||||
// providerUsesKey 表示该预设把凭据放在 Webhook.Key(而不是 WebhookSecret)里。
|
||||
// 保存时的「已配置」判定与密钥落库都按这份名单走,新增预设时只需改这一处。
|
||||
func providerUsesKey(provider WebhookProvider) bool {
|
||||
switch provider {
|
||||
case WebhookBark, WebhookNtfy, WebhookGotify, WebhookWecom, WebhookDingtalk, WebhookFeishu:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type EmailConfig struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
@@ -64,22 +91,28 @@ type TelegramConfig struct {
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Type NotifyType `json:"type"`
|
||||
Email EmailConfig `json:"email"`
|
||||
Webhook WebhookConfig `json:"webhook"`
|
||||
Type NotifyType `json:"type"`
|
||||
Email EmailConfig `json:"email"`
|
||||
Webhook WebhookConfig `json:"webhook"`
|
||||
Telegram TelegramConfig `json:"telegram"`
|
||||
OnDDNSIPChange bool `json:"on_ddns_ip_change"`
|
||||
OnDDNSFailure bool `json:"on_ddns_failure"`
|
||||
OnCertExpiry bool `json:"on_cert_expiry"`
|
||||
OnCertRenewSuccess bool `json:"on_cert_renew_success"`
|
||||
OnCertRenewFailure bool `json:"on_cert_renew_failure"`
|
||||
OnIPFrequentAccess bool `json:"on_ip_frequent_access"`
|
||||
OnLoginFailure bool `json:"on_login_failure"`
|
||||
OnNginxReloadFailure bool `json:"on_nginx_reload_failure"`
|
||||
IPFrequentThreshold int `json:"ip_frequent_threshold"`
|
||||
IPFrequentWindowSec int `json:"ip_frequent_window_sec"`
|
||||
LoginFailureThreshold int `json:"login_failure_threshold"`
|
||||
LoginFailureWindowSec int `json:"login_failure_window_sec"`
|
||||
OnDDNSIPChange bool `json:"on_ddns_ip_change"`
|
||||
OnDDNSFailure bool `json:"on_ddns_failure"`
|
||||
OnCertExpiry bool `json:"on_cert_expiry"`
|
||||
OnCertRenewSuccess bool `json:"on_cert_renew_success"`
|
||||
OnCertRenewFailure bool `json:"on_cert_renew_failure"`
|
||||
OnIPFrequentAccess bool `json:"on_ip_frequent_access"`
|
||||
OnLoginFailure bool `json:"on_login_failure"`
|
||||
OnNginxReloadFailure bool `json:"on_nginx_reload_failure"`
|
||||
IPFrequentThreshold int `json:"ip_frequent_threshold"`
|
||||
IPFrequentWindowSec int `json:"ip_frequent_window_sec"`
|
||||
LoginFailureThreshold int `json:"login_failure_threshold"`
|
||||
LoginFailureWindowSec int `json:"login_failure_window_sec"`
|
||||
OnResourceThreshold bool `json:"on_resource_threshold"`
|
||||
OnTunnelDown bool `json:"on_tunnel_down"`
|
||||
CPUThreshold int `json:"cpu_threshold"`
|
||||
MemoryThreshold int `json:"memory_threshold"`
|
||||
DiskThreshold int `json:"disk_threshold"`
|
||||
TunnelFailThreshold int `json:"tunnel_fail_threshold"`
|
||||
}
|
||||
|
||||
type RuntimeConfig struct {
|
||||
@@ -90,25 +123,31 @@ type RuntimeConfig struct {
|
||||
}
|
||||
|
||||
type SaveInput struct {
|
||||
Type NotifyType
|
||||
Email EmailConfig
|
||||
SMTPPassword string
|
||||
Webhook WebhookConfig
|
||||
WebhookSecret string
|
||||
Telegram TelegramConfig
|
||||
TelegramToken string
|
||||
OnDDNSIPChange bool
|
||||
OnDDNSFailure bool
|
||||
OnCertExpiry bool
|
||||
OnCertRenewSuccess bool
|
||||
OnCertRenewFailure bool
|
||||
OnIPFrequentAccess bool
|
||||
OnLoginFailure bool
|
||||
OnNginxReloadFailure bool
|
||||
IPFrequentThreshold int
|
||||
IPFrequentWindowSec int
|
||||
LoginFailureThreshold int
|
||||
LoginFailureWindowSec int
|
||||
Type NotifyType
|
||||
Email EmailConfig
|
||||
SMTPPassword string
|
||||
Webhook WebhookConfig
|
||||
WebhookSecret string
|
||||
Telegram TelegramConfig
|
||||
TelegramToken string
|
||||
OnDDNSIPChange bool
|
||||
OnDDNSFailure bool
|
||||
OnCertExpiry bool
|
||||
OnCertRenewSuccess bool
|
||||
OnCertRenewFailure bool
|
||||
OnIPFrequentAccess bool
|
||||
OnLoginFailure bool
|
||||
OnNginxReloadFailure bool
|
||||
IPFrequentThreshold int
|
||||
IPFrequentWindowSec int
|
||||
LoginFailureThreshold int
|
||||
LoginFailureWindowSec int
|
||||
OnResourceThreshold bool
|
||||
OnTunnelDown bool
|
||||
CPUThreshold int
|
||||
MemoryThreshold int
|
||||
DiskThreshold int
|
||||
TunnelFailThreshold int
|
||||
}
|
||||
|
||||
type TestInput struct {
|
||||
|
||||
+75
-10
@@ -20,6 +20,12 @@ func SendWebhook(ctx context.Context, client *http.Client, cfg RuntimeConfig, co
|
||||
return sendNtfy(ctx, client, cfg, content.PushTitle, content.PlainBody)
|
||||
case WebhookGotify:
|
||||
return sendGotify(ctx, client, cfg, content.PushTitle, content.PlainBody)
|
||||
case WebhookWecom:
|
||||
return sendWecom(ctx, client, cfg, content.PushTitle, content.PlainBody)
|
||||
case WebhookDingtalk:
|
||||
return sendDingtalk(ctx, client, cfg, content.PushTitle, content.PlainBody)
|
||||
case WebhookFeishu:
|
||||
return sendFeishu(ctx, client, cfg, content.PushTitle, content.PlainBody)
|
||||
case WebhookCustom:
|
||||
return sendCustomWebhook(ctx, client, cfg, content)
|
||||
default:
|
||||
@@ -58,7 +64,7 @@ func sendNtfy(ctx context.Context, client *http.Client, cfg RuntimeConfig, title
|
||||
}
|
||||
server := normalizeServerURL(cfg.Webhook.Server)
|
||||
if server == "" {
|
||||
server = "https://ntfy.sh"
|
||||
server = DefaultNtfyServer
|
||||
}
|
||||
endpoint := joinURL(server, topic)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(message))
|
||||
@@ -83,18 +89,69 @@ func sendGotify(ctx context.Context, client *http.Client, cfg RuntimeConfig, tit
|
||||
return fmt.Errorf("Gotify 服务地址未配置")
|
||||
}
|
||||
endpoint := joinURL(server, "message") + "?token=" + url.QueryEscape(token)
|
||||
payload := map[string]any{
|
||||
return postJSON(ctx, client, endpoint, map[string]any{
|
||||
"title": title,
|
||||
"message": message,
|
||||
"priority": 5,
|
||||
})
|
||||
}
|
||||
|
||||
// 企业微信群机器人:POST /cgi-bin/webhook/send?key=<key>,markdown 正文
|
||||
func sendWecom(ctx context.Context, client *http.Client, cfg RuntimeConfig, title, message string) error {
|
||||
key := webhookSecret(cfg)
|
||||
if key == "" {
|
||||
return fmt.Errorf("企业微信机器人 Key 未配置")
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
server := normalizeServerURL(cfg.Webhook.Server)
|
||||
if server == "" {
|
||||
server = DefaultWecomServer
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return doRequest(client, req)
|
||||
endpoint := joinURL(server, "cgi-bin", "webhook", "send") + "?key=" + url.QueryEscape(key)
|
||||
return postJSON(ctx, client, endpoint, map[string]any{
|
||||
"msgtype": "markdown",
|
||||
"markdown": map[string]string{"content": markdownBody(title, message)},
|
||||
})
|
||||
}
|
||||
|
||||
// 钉钉群机器人:POST /robot/send?access_token=<key>。
|
||||
// 正文固定带 “Fonu · ” 前缀,机器人用「自定义关键词 = Fonu」的安全设置即可命中。
|
||||
func sendDingtalk(ctx context.Context, client *http.Client, cfg RuntimeConfig, title, message string) error {
|
||||
key := webhookSecret(cfg)
|
||||
if key == "" {
|
||||
return fmt.Errorf("钉钉机器人 Access Token 未配置")
|
||||
}
|
||||
server := normalizeServerURL(cfg.Webhook.Server)
|
||||
if server == "" {
|
||||
server = DefaultDingtalkServer
|
||||
}
|
||||
titled := "Fonu · " + title
|
||||
endpoint := joinURL(server, "robot", "send") + "?access_token=" + url.QueryEscape(key)
|
||||
return postJSON(ctx, client, endpoint, map[string]any{
|
||||
"msgtype": "markdown",
|
||||
"markdown": map[string]string{"title": titled, "text": markdownBody(titled, message)},
|
||||
})
|
||||
}
|
||||
|
||||
// 飞书自定义机器人:POST /open-apis/bot/v2/hook/<key>,纯文本消息
|
||||
func sendFeishu(ctx context.Context, client *http.Client, cfg RuntimeConfig, title, message string) error {
|
||||
key := webhookSecret(cfg)
|
||||
if key == "" {
|
||||
return fmt.Errorf("飞书机器人 Key 未配置")
|
||||
}
|
||||
server := normalizeServerURL(cfg.Webhook.Server)
|
||||
if server == "" {
|
||||
server = DefaultFeishuServer
|
||||
}
|
||||
endpoint := joinURL(server, "open-apis", "bot", "v2", "hook", key)
|
||||
return postJSON(ctx, client, endpoint, map[string]any{
|
||||
"msg_type": "text",
|
||||
"content": map[string]string{"text": title + "\n" + message},
|
||||
})
|
||||
}
|
||||
|
||||
// markdownBody 拼出国内 IM 机器人都能渲染的 markdown(企业微信 / 钉钉都认 ## 标题 + 正文)
|
||||
func markdownBody(title, message string) string {
|
||||
return "## " + title + "\n" + message
|
||||
}
|
||||
|
||||
func sendCustomWebhook(ctx context.Context, client *http.Client, cfg RuntimeConfig, content AlertContent) error {
|
||||
@@ -102,8 +159,16 @@ func sendCustomWebhook(ctx context.Context, client *http.Client, cfg RuntimeConf
|
||||
if target == "" {
|
||||
return fmt.Errorf("Webhook URL 未配置")
|
||||
}
|
||||
body, _ := json.Marshal(content.webhookPayload())
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target, bytes.NewReader(body))
|
||||
return postJSON(ctx, client, target, content.webhookPayload())
|
||||
}
|
||||
|
||||
// postJSON 发一个 JSON POST:各国内 IM 机器人都是这个形状,只是 URL 与 payload 不同。
|
||||
func postJSON(ctx context.Context, client *http.Client, endpoint string, payload any) error {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -71,3 +71,93 @@ func TestJoinURL(t *testing.T) {
|
||||
t.Fatalf("unexpected url: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 国内 IM 机器人:固定路径 + 固定 payload 形状,这里把三家的端点与请求体锁住
|
||||
func TestSendWecomUsesKeyAndMarkdown(t *testing.T) {
|
||||
var path, query, body string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path = r.URL.Path
|
||||
query = r.URL.RawQuery
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
body = string(raw)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := RuntimeConfig{Config: Config{
|
||||
Type: NotifyTypeWebhook,
|
||||
Webhook: WebhookConfig{Provider: WebhookWecom, Server: server.URL, Key: "abc"},
|
||||
}}
|
||||
if err := SendWebhook(context.Background(), server.Client(), cfg, FormatAlert("test", "标题", "内容")); err != nil {
|
||||
t.Fatalf("send wecom: %v", err)
|
||||
}
|
||||
if path != "/cgi-bin/webhook/send" || query != "key=abc" {
|
||||
t.Fatalf("unexpected endpoint: %s?%s", path, query)
|
||||
}
|
||||
if !strings.Contains(body, `"msgtype":"markdown"`) || !strings.Contains(body, "标题") {
|
||||
t.Fatalf("unexpected body: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendDingtalkUsesAccessTokenAndKeyword(t *testing.T) {
|
||||
var path, query, body string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path = r.URL.Path
|
||||
query = r.URL.RawQuery
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
body = string(raw)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := RuntimeConfig{Config: Config{
|
||||
Type: NotifyTypeWebhook,
|
||||
Webhook: WebhookConfig{Provider: WebhookDingtalk, Server: server.URL, Key: "tok"},
|
||||
}}
|
||||
if err := SendWebhook(context.Background(), server.Client(), cfg, FormatAlert("test", "标题", "内容")); err != nil {
|
||||
t.Fatalf("send dingtalk: %v", err)
|
||||
}
|
||||
if path != "/robot/send" || query != "access_token=tok" {
|
||||
t.Fatalf("unexpected endpoint: %s?%s", path, query)
|
||||
}
|
||||
// 「自定义关键词」安全设置靠正文里的 Fonu 命中(PushTitle 前面还有事件图标)
|
||||
if !strings.Contains(body, "Fonu · ") || !strings.Contains(body, "标题") {
|
||||
t.Fatalf("missing keyword prefix: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendFeishuUsesHookPath(t *testing.T) {
|
||||
var path, body string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path = r.URL.Path
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
body = string(raw)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := RuntimeConfig{Config: Config{
|
||||
Type: NotifyTypeWebhook,
|
||||
Webhook: WebhookConfig{Provider: WebhookFeishu, Server: server.URL, Key: "hookkey"},
|
||||
}}
|
||||
if err := SendWebhook(context.Background(), server.Client(), cfg, FormatAlert("test", "标题", "内容")); err != nil {
|
||||
t.Fatalf("send feishu: %v", err)
|
||||
}
|
||||
if path != "/open-apis/bot/v2/hook/hookkey" {
|
||||
t.Fatalf("unexpected path: %s", path)
|
||||
}
|
||||
if !strings.Contains(body, `"msg_type":"text"`) {
|
||||
t.Fatalf("unexpected body: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderUsesKeyCoversKeyBasedPresets(t *testing.T) {
|
||||
for _, provider := range []WebhookProvider{WebhookBark, WebhookNtfy, WebhookGotify, WebhookWecom, WebhookDingtalk, WebhookFeishu} {
|
||||
if !providerUsesKey(provider) {
|
||||
t.Fatalf("%s 的凭据存在 Webhook.Key,密钥落库路径必须覆盖它", provider)
|
||||
}
|
||||
}
|
||||
if providerUsesKey(WebhookCustom) {
|
||||
t.Fatal("自定义 Webhook 的凭据来自 URL,不应走 Key 分支")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,6 +242,10 @@ func (s *Service) enabledFor(cfg RuntimeConfig, event string) bool {
|
||||
return cfg.OnLoginFailure
|
||||
case EventNginxReloadFailure:
|
||||
return cfg.OnNginxReloadFailure
|
||||
case EventResourceThreshold:
|
||||
return cfg.OnResourceThreshold
|
||||
case EventTunnelDown:
|
||||
return cfg.OnTunnelDown
|
||||
default:
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -76,6 +76,10 @@ func (s *Store) load(ctx context.Context, decrypt bool) (Config, runtimeSecrets,
|
||||
|
||||
cfg.LoginFailureThreshold = intSetting(s.settings, ctx, settings.KeyNotifyLoginFailureThreshold, DefaultLoginFailureThreshold)
|
||||
cfg.LoginFailureWindowSec = intSetting(s.settings, ctx, settings.KeyNotifyLoginFailureWindowSec, DefaultLoginFailureWindowSec)
|
||||
cfg.CPUThreshold = intSetting(s.settings, ctx, settings.KeyNotifyCPUThreshold, DefaultCPUThreshold)
|
||||
cfg.MemoryThreshold = intSetting(s.settings, ctx, settings.KeyNotifyMemoryThreshold, DefaultMemoryThreshold)
|
||||
cfg.DiskThreshold = intSetting(s.settings, ctx, settings.KeyNotifyDiskThreshold, DefaultDiskThreshold)
|
||||
cfg.TunnelFailThreshold = intSetting(s.settings, ctx, settings.KeyNotifyTunnelFailThreshold, DefaultTunnelFailThreshold)
|
||||
|
||||
if hasNotifyEventSettings(ctx, s.settings) {
|
||||
cfg.OnDDNSIPChange = flag(s.settings, ctx, settings.KeyNotifyOnDDNSIPChange, true)
|
||||
@@ -86,6 +90,9 @@ func (s *Store) load(ctx context.Context, decrypt bool) (Config, runtimeSecrets,
|
||||
cfg.OnIPFrequentAccess = flag(s.settings, ctx, settings.KeyNotifyOnIPFrequentAccess, false)
|
||||
cfg.OnLoginFailure = flag(s.settings, ctx, settings.KeyNotifyOnLoginFailure, false)
|
||||
cfg.OnNginxReloadFailure = flag(s.settings, ctx, settings.KeyNotifyOnNginxReloadFailure, true)
|
||||
// 资源与隧道告警默认关闭:升级后不改变现有用户收到的通知
|
||||
cfg.OnResourceThreshold = flag(s.settings, ctx, settings.KeyNotifyOnResourceThreshold, false)
|
||||
cfg.OnTunnelDown = flag(s.settings, ctx, settings.KeyNotifyOnTunnelDown, false)
|
||||
} else {
|
||||
legacyDDNS := flag(s.settings, ctx, settings.KeyNotifyOnDDNSError, true)
|
||||
legacyCert := flag(s.settings, ctx, settings.KeyNotifyOnCertError, true)
|
||||
@@ -184,6 +191,8 @@ func hasNotifyEventSettings(ctx context.Context, store *settings.Store) bool {
|
||||
settings.KeyNotifyOnCertRenewFailure,
|
||||
settings.KeyNotifyOnLoginFailure,
|
||||
settings.KeyNotifyOnNginxReloadFailure,
|
||||
settings.KeyNotifyOnResourceThreshold,
|
||||
settings.KeyNotifyOnTunnelDown,
|
||||
}
|
||||
for _, key := range keys {
|
||||
raw, err := store.Get(ctx, key)
|
||||
@@ -226,6 +235,24 @@ func (s *Store) Save(ctx context.Context, in SaveInput) error {
|
||||
if err := s.settings.SetBool(ctx, settings.KeyNotifyOnNginxReloadFailure, in.OnNginxReloadFailure); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetBool(ctx, settings.KeyNotifyOnResourceThreshold, in.OnResourceThreshold); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetBool(ctx, settings.KeyNotifyOnTunnelDown, in.OnTunnelDown); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetInt(ctx, settings.KeyNotifyCPUThreshold, clampThreshold(in.CPUThreshold, DefaultCPUThreshold)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetInt(ctx, settings.KeyNotifyMemoryThreshold, clampThreshold(in.MemoryThreshold, DefaultMemoryThreshold)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetInt(ctx, settings.KeyNotifyDiskThreshold, clampThreshold(in.DiskThreshold, DefaultDiskThreshold)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetInt(ctx, settings.KeyNotifyTunnelFailThreshold, clampThreshold(in.TunnelFailThreshold, DefaultTunnelFailThreshold)); err != nil {
|
||||
return err
|
||||
}
|
||||
threshold := in.IPFrequentThreshold
|
||||
if threshold <= 0 {
|
||||
threshold = DefaultIPFrequentThreshold
|
||||
@@ -269,7 +296,7 @@ func (s *Store) Save(ctx context.Context, in SaveInput) error {
|
||||
secretValue := strings.TrimSpace(in.WebhookSecret)
|
||||
if secretValue != "" && secretValue != MaskedSecret {
|
||||
webhook.HasSecret = true
|
||||
} else if webhook.Provider == WebhookBark || webhook.Provider == WebhookGotify {
|
||||
} else if providerUsesKey(webhook.Provider) {
|
||||
if strings.TrimSpace(webhook.Key) != "" && webhook.Key != MaskedSecret {
|
||||
webhook.HasSecret = true
|
||||
}
|
||||
@@ -306,11 +333,7 @@ func (s *Store) Save(ctx context.Context, in SaveInput) error {
|
||||
|
||||
webhookPlain := secretValue
|
||||
if webhookPlain == "" || webhookPlain == MaskedSecret {
|
||||
if webhook.Provider == WebhookBark || webhook.Provider == WebhookGotify {
|
||||
if strings.TrimSpace(webhook.Key) != "" && webhook.Key != MaskedSecret {
|
||||
webhookPlain = strings.TrimSpace(webhook.Key)
|
||||
}
|
||||
} else if webhook.Provider == WebhookNtfy {
|
||||
if providerUsesKey(webhook.Provider) {
|
||||
if strings.TrimSpace(webhook.Key) != "" && webhook.Key != MaskedSecret {
|
||||
webhookPlain = strings.TrimSpace(webhook.Key)
|
||||
}
|
||||
@@ -376,6 +399,18 @@ func validateSaveInput(in SaveInput) error {
|
||||
if strings.TrimSpace(in.Webhook.Key) == "" && !in.Webhook.HasSecret {
|
||||
return fmt.Errorf("请填写 Gotify App Token")
|
||||
}
|
||||
case WebhookWecom:
|
||||
if strings.TrimSpace(in.Webhook.Key) == "" && !in.Webhook.HasSecret {
|
||||
return fmt.Errorf("请填写企业微信机器人 Key")
|
||||
}
|
||||
case WebhookDingtalk:
|
||||
if strings.TrimSpace(in.Webhook.Key) == "" && !in.Webhook.HasSecret {
|
||||
return fmt.Errorf("请填写钉钉机器人 Access Token")
|
||||
}
|
||||
case WebhookFeishu:
|
||||
if strings.TrimSpace(in.Webhook.Key) == "" && !in.Webhook.HasSecret {
|
||||
return fmt.Errorf("请填写飞书机器人 Key")
|
||||
}
|
||||
case WebhookCustom:
|
||||
if strings.TrimSpace(in.Webhook.URL) == "" {
|
||||
return fmt.Errorf("请填写 Webhook URL")
|
||||
@@ -452,9 +487,27 @@ func ParseSaveInputFromMap(values map[string]string) (SaveInput, bool) {
|
||||
in.IPFrequentWindowSec = intSettingFromMap(values, settings.KeyNotifyIPFrequentWindowSec, DefaultIPFrequentWindowSec)
|
||||
in.LoginFailureThreshold = intSettingFromMap(values, settings.KeyNotifyLoginFailureThreshold, DefaultLoginFailureThreshold)
|
||||
in.LoginFailureWindowSec = intSettingFromMap(values, settings.KeyNotifyLoginFailureWindowSec, DefaultLoginFailureWindowSec)
|
||||
in.OnResourceThreshold = values[settings.KeyNotifyOnResourceThreshold] == "1" || strings.EqualFold(values[settings.KeyNotifyOnResourceThreshold], "true")
|
||||
in.OnTunnelDown = values[settings.KeyNotifyOnTunnelDown] == "1" || strings.EqualFold(values[settings.KeyNotifyOnTunnelDown], "true")
|
||||
in.CPUThreshold = intSettingFromMap(values, settings.KeyNotifyCPUThreshold, DefaultCPUThreshold)
|
||||
in.MemoryThreshold = intSettingFromMap(values, settings.KeyNotifyMemoryThreshold, DefaultMemoryThreshold)
|
||||
in.DiskThreshold = intSettingFromMap(values, settings.KeyNotifyDiskThreshold, DefaultDiskThreshold)
|
||||
in.TunnelFailThreshold = intSettingFromMap(values, settings.KeyNotifyTunnelFailThreshold, DefaultTunnelFailThreshold)
|
||||
return in, true
|
||||
}
|
||||
|
||||
// clampThreshold 把未配置或越界的阈值拉回合法范围:0/负数用默认值,超过 100 的按 100 处理
|
||||
// (百分比阈值与「连续失败次数」的上限都用这一条)。
|
||||
func clampThreshold(value, fallback int) int {
|
||||
if value <= 0 {
|
||||
return fallback
|
||||
}
|
||||
if value > 100 {
|
||||
return 100
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func intSettingFromMap(values map[string]string, key string, fallback int) int {
|
||||
raw := strings.TrimSpace(values[key])
|
||||
if raw == "" {
|
||||
|
||||
@@ -25,11 +25,11 @@ type RuleNginxView struct {
|
||||
}
|
||||
|
||||
type GlobalNginxView struct {
|
||||
Mode string `json:"mode"`
|
||||
GeneratedFramework string `json:"generated_framework"`
|
||||
GeneratedSnippet string `json:"generated_snippet"`
|
||||
Content string `json:"content"`
|
||||
Backups []NginxBackupInfo `json:"backups"`
|
||||
Mode string `json:"mode"`
|
||||
GeneratedFramework string `json:"generated_framework"`
|
||||
GeneratedSnippet string `json:"generated_snippet"`
|
||||
Content string `json:"content"`
|
||||
Backups []NginxBackupInfo `json:"backups"`
|
||||
}
|
||||
|
||||
type SaveRuleNginxInput struct {
|
||||
@@ -351,3 +351,68 @@ func mapBackups(entries []nginx.BackupEntry) []NginxBackupInfo {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// NginxConfigVersionInfo 描述主配置(nginx.conf)的一个历史版本。
|
||||
type NginxConfigVersionInfo struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type NginxConfigVersionsView struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
Content string `json:"content"`
|
||||
Versions []NginxConfigVersionInfo `json:"versions"`
|
||||
}
|
||||
|
||||
type NginxConfigVersionView struct {
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// GetNginxConfigVersions 返回当前主配置与其历史版本,前端拿两者做差异对比。
|
||||
func (s *ProxyService) GetNginxConfigVersions() (NginxConfigVersionsView, error) {
|
||||
content, err := s.nginx.ReadConfig()
|
||||
if err != nil {
|
||||
return NginxConfigVersionsView{}, err
|
||||
}
|
||||
versions, err := s.nginx.ListConfigVersions()
|
||||
if err != nil {
|
||||
return NginxConfigVersionsView{}, err
|
||||
}
|
||||
return NginxConfigVersionsView{Content: content, Versions: mapConfigVersions(versions)}, nil
|
||||
}
|
||||
|
||||
func (s *ProxyService) ReadNginxConfigVersion(name string) (NginxConfigVersionView, error) {
|
||||
content, err := s.nginx.ReadConfigVersion(name)
|
||||
if err != nil {
|
||||
return NginxConfigVersionView{}, err
|
||||
}
|
||||
return NginxConfigVersionView{Name: name, Content: content}, nil
|
||||
}
|
||||
|
||||
// RollbackNginxConfigVersion 回滚主配置;校验不过时 nginx 层不会动磁盘上的当前配置。
|
||||
func (s *ProxyService) RollbackNginxConfigVersion(ctx context.Context, name string) (NginxConfigVersionsView, error) {
|
||||
result, err := s.nginx.RollbackConfigVersion(ctx, name)
|
||||
if err != nil {
|
||||
return NginxConfigVersionsView{}, err
|
||||
}
|
||||
view, err := s.GetNginxConfigVersions()
|
||||
if err != nil {
|
||||
return NginxConfigVersionsView{}, err
|
||||
}
|
||||
view.Message = result.Message
|
||||
return view, nil
|
||||
}
|
||||
|
||||
func mapConfigVersions(entries []nginx.ConfigVersion) []NginxConfigVersionInfo {
|
||||
out := make([]NginxConfigVersionInfo, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
out = append(out, NginxConfigVersionInfo{
|
||||
Name: entry.Name,
|
||||
Size: entry.Size,
|
||||
CreatedAt: entry.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -8,68 +8,74 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
KeyTheme = "theme"
|
||||
KeyTimezone = "timezone"
|
||||
KeyLogRetentionDays = "log_retention_days"
|
||||
KeyDDNSCheckInterval = "ddns_check_interval_minutes"
|
||||
KeyCertRenewThreshold = "cert_renew_threshold_days"
|
||||
KeyRootDomain = "root_domain"
|
||||
KeyACMEEmail = "acme_email"
|
||||
KeyACMECA = "acme_ca"
|
||||
KeyZeroSSLAPIKey = "zerossl_api_key"
|
||||
KeyNotifyWebhookURL = "notify_webhook_url"
|
||||
KeyNotifyType = "notify_type"
|
||||
KeyNotifyEmailJSON = "notify_email_json"
|
||||
KeyNotifySMTPPassword = "notify_smtp_password"
|
||||
KeyNotifyWebhookJSON = "notify_webhook_json"
|
||||
KeyNotifyWebhookSecret = "notify_webhook_secret"
|
||||
KeyNotifyTelegramJSON = "notify_telegram_json"
|
||||
KeyNotifyTelegramToken = "notify_telegram_token"
|
||||
KeyNotifyOnDDNSError = "notify_on_ddns_error" // legacy, migrated to KeyNotifyOnDDNSFailure
|
||||
KeyNotifyOnCertError = "notify_on_cert_error" // legacy, migrated to KeyNotifyOnCertExpiry
|
||||
KeyNotifyOnNginxError = "notify_on_nginx_error" // legacy, ignored
|
||||
KeyNotifyOnDDNSIPChange = "notify_on_ddns_ip_change"
|
||||
KeyNotifyOnDDNSFailure = "notify_on_ddns_failure"
|
||||
KeyNotifyOnCertExpiry = "notify_on_cert_expiry"
|
||||
KeyNotifyOnCertRenewSuccess = "notify_on_cert_renew_success"
|
||||
KeyNotifyOnIPFrequentAccess = "notify_on_ip_frequent_access"
|
||||
KeyNotifyOnCertRenewFailure = "notify_on_cert_renew_failure"
|
||||
KeyNotifyOnLoginFailure = "notify_on_login_failure"
|
||||
KeyNotifyOnNginxReloadFailure = "notify_on_nginx_reload_failure"
|
||||
KeyNotifyIPFrequentThreshold = "notify_ip_frequent_threshold"
|
||||
KeyNotifyIPFrequentWindowSec = "notify_ip_frequent_window_sec"
|
||||
KeyNotifyLoginFailureThreshold = "notify_login_failure_threshold"
|
||||
KeyNotifyLoginFailureWindowSec = "notify_login_failure_window_sec"
|
||||
KeyTrustedProxy = "trusted_proxy_json"
|
||||
KeyGlobalIPBlacklist = "global_ip_blacklist"
|
||||
KeyGlobalIPWhitelist = "global_ip_whitelist"
|
||||
KeyChinaCIDRUpdateHours = "china_cidr_update_interval_hours"
|
||||
KeyChinaCIDRSourceV4 = "china_cidr_source_url_v4"
|
||||
KeyChinaCIDRSourceV6 = "china_cidr_source_url_v6"
|
||||
KeyChinaCIDRUpdatedAt = "china_cidr_updated_at"
|
||||
KeyChinaCIDRCountV4 = "china_cidr_count_v4"
|
||||
KeyChinaCIDRCountV6 = "china_cidr_count_v6"
|
||||
KeyChinaCIDRLastError = "china_cidr_last_error"
|
||||
KeyFRPEnabled = "frp_enabled"
|
||||
KeyFRPServerAddr = "frp_server_addr"
|
||||
KeyFRPServerPort = "frp_server_port"
|
||||
KeyFRPAuthToken = "frp_auth_token"
|
||||
KeyFRPTLSEnabled = "frp_tls_enabled"
|
||||
KeyFRPCustomDomains = "frp_custom_domains"
|
||||
KeyFRPLastError = "frp_last_error"
|
||||
KeyFRPStartedAt = "frp_started_at"
|
||||
KeyNginxGlobalMode = "nginx_global_mode"
|
||||
KeyTheme = "theme"
|
||||
KeyTimezone = "timezone"
|
||||
KeyLogRetentionDays = "log_retention_days"
|
||||
KeyDDNSCheckInterval = "ddns_check_interval_minutes"
|
||||
KeyCertRenewThreshold = "cert_renew_threshold_days"
|
||||
KeyRootDomain = "root_domain"
|
||||
KeyACMEEmail = "acme_email"
|
||||
KeyACMECA = "acme_ca"
|
||||
KeyZeroSSLAPIKey = "zerossl_api_key"
|
||||
KeyNotifyWebhookURL = "notify_webhook_url"
|
||||
KeyNotifyType = "notify_type"
|
||||
KeyNotifyEmailJSON = "notify_email_json"
|
||||
KeyNotifySMTPPassword = "notify_smtp_password"
|
||||
KeyNotifyWebhookJSON = "notify_webhook_json"
|
||||
KeyNotifyWebhookSecret = "notify_webhook_secret"
|
||||
KeyNotifyTelegramJSON = "notify_telegram_json"
|
||||
KeyNotifyTelegramToken = "notify_telegram_token"
|
||||
KeyNotifyOnDDNSError = "notify_on_ddns_error" // legacy, migrated to KeyNotifyOnDDNSFailure
|
||||
KeyNotifyOnCertError = "notify_on_cert_error" // legacy, migrated to KeyNotifyOnCertExpiry
|
||||
KeyNotifyOnNginxError = "notify_on_nginx_error" // legacy, ignored
|
||||
KeyNotifyOnDDNSIPChange = "notify_on_ddns_ip_change"
|
||||
KeyNotifyOnDDNSFailure = "notify_on_ddns_failure"
|
||||
KeyNotifyOnCertExpiry = "notify_on_cert_expiry"
|
||||
KeyNotifyOnCertRenewSuccess = "notify_on_cert_renew_success"
|
||||
KeyNotifyOnIPFrequentAccess = "notify_on_ip_frequent_access"
|
||||
KeyNotifyOnCertRenewFailure = "notify_on_cert_renew_failure"
|
||||
KeyNotifyOnLoginFailure = "notify_on_login_failure"
|
||||
KeyNotifyOnNginxReloadFailure = "notify_on_nginx_reload_failure"
|
||||
KeyNotifyIPFrequentThreshold = "notify_ip_frequent_threshold"
|
||||
KeyNotifyIPFrequentWindowSec = "notify_ip_frequent_window_sec"
|
||||
KeyNotifyLoginFailureThreshold = "notify_login_failure_threshold"
|
||||
KeyNotifyLoginFailureWindowSec = "notify_login_failure_window_sec"
|
||||
KeyNotifyOnResourceThreshold = "notify_on_resource_threshold"
|
||||
KeyNotifyOnTunnelDown = "notify_on_tunnel_down"
|
||||
KeyNotifyCPUThreshold = "notify_cpu_threshold"
|
||||
KeyNotifyMemoryThreshold = "notify_memory_threshold"
|
||||
KeyNotifyDiskThreshold = "notify_disk_threshold"
|
||||
KeyNotifyTunnelFailThreshold = "notify_tunnel_fail_threshold"
|
||||
KeyTrustedProxy = "trusted_proxy_json"
|
||||
KeyGlobalIPBlacklist = "global_ip_blacklist"
|
||||
KeyGlobalIPWhitelist = "global_ip_whitelist"
|
||||
KeyChinaCIDRUpdateHours = "china_cidr_update_interval_hours"
|
||||
KeyChinaCIDRSourceV4 = "china_cidr_source_url_v4"
|
||||
KeyChinaCIDRSourceV6 = "china_cidr_source_url_v6"
|
||||
KeyChinaCIDRUpdatedAt = "china_cidr_updated_at"
|
||||
KeyChinaCIDRCountV4 = "china_cidr_count_v4"
|
||||
KeyChinaCIDRCountV6 = "china_cidr_count_v6"
|
||||
KeyChinaCIDRLastError = "china_cidr_last_error"
|
||||
KeyFRPEnabled = "frp_enabled"
|
||||
KeyFRPServerAddr = "frp_server_addr"
|
||||
KeyFRPServerPort = "frp_server_port"
|
||||
KeyFRPAuthToken = "frp_auth_token"
|
||||
KeyFRPTLSEnabled = "frp_tls_enabled"
|
||||
KeyFRPCustomDomains = "frp_custom_domains"
|
||||
KeyFRPLastError = "frp_last_error"
|
||||
KeyFRPStartedAt = "frp_started_at"
|
||||
KeyNginxGlobalMode = "nginx_global_mode"
|
||||
)
|
||||
|
||||
var Defaults = map[string]string{
|
||||
KeyTheme: "system",
|
||||
KeyTimezone: "Asia/Shanghai",
|
||||
KeyLogRetentionDays: "30",
|
||||
KeyDDNSCheckInterval: "5",
|
||||
KeyCertRenewThreshold: "30",
|
||||
KeyACMECA: "letsencrypt",
|
||||
KeyChinaCIDRUpdateHours: "24",
|
||||
KeyNginxGlobalMode: "auto",
|
||||
KeyTheme: "system",
|
||||
KeyTimezone: "Asia/Shanghai",
|
||||
KeyLogRetentionDays: "30",
|
||||
KeyDDNSCheckInterval: "5",
|
||||
KeyCertRenewThreshold: "30",
|
||||
KeyACMECA: "letsencrypt",
|
||||
KeyChinaCIDRUpdateHours: "24",
|
||||
KeyNginxGlobalMode: "auto",
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
-- API Token:给无法保存会话 cookie 的调用方(脚本 / HomeAssistant / 手机快捷指令)用。
|
||||
-- token_hash 是明文令牌的 SHA-256:明文只在创建响应里返回一次,库里不留。
|
||||
-- scope: read = 只允许 GET/HEAD,write = 与登录会话同权。
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
scope TEXT NOT NULL DEFAULT 'read',
|
||||
expires_at TEXT,
|
||||
last_used_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "fonu-web",
|
||||
"private": true,
|
||||
"version": "1.2.8",
|
||||
"version": "1.2.9",
|
||||
"packageManager": "npm@10.9.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -223,7 +223,24 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(backup ? { backup } : {}),
|
||||
}),
|
||||
getNginxConfigVersions: () =>
|
||||
request<import('./types').NginxConfigVersionsView>('/api/settings/nginx/config/versions'),
|
||||
getNginxConfigVersion: (name: string) =>
|
||||
request<import('./types').NginxConfigVersionView>(`/api/settings/nginx/config/versions/${encodeURIComponent(name)}`),
|
||||
rollbackNginxConfigVersion: (name: string) =>
|
||||
request<import('./types').NginxConfigVersionsView>(
|
||||
`/api/settings/nginx/config/versions/${encodeURIComponent(name)}/rollback`,
|
||||
{ method: 'POST' },
|
||||
),
|
||||
getChinaCIDRStatus: () => request<ChinaCIDRStatus>('/api/settings/china-cidr'),
|
||||
listApiTokens: () => request<{ tokens: import('./types').ApiToken[] }>('/api/settings/tokens'),
|
||||
createApiToken: (payload: { name: string; scope: 'read' | 'write'; expires_in_days: number }) =>
|
||||
request<import('./types').CreatedApiToken>('/api/settings/tokens', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
deleteApiToken: (id: number) =>
|
||||
request<{ ok: boolean }>(`/api/settings/tokens/${id}`, { method: 'DELETE' }),
|
||||
refreshChinaCIDR: () =>
|
||||
request<ChinaCIDRStatus>('/api/settings/china-cidr/refresh', { method: 'POST' }),
|
||||
testNotify: (payload: NotifyTestPayload) =>
|
||||
@@ -315,6 +332,17 @@ export const api = {
|
||||
deployFrpAgentRoute: (serverId: number, proxyId: number) => request<{ ok: boolean; basic_auth_user?: string; basic_auth_password?: string }>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}`, { method: 'POST' }),
|
||||
getFrpAgentRouteConf: (serverId: number, proxyId: number) =>
|
||||
request<{ proxy_id: number; domain: string; content: string }>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}/conf`),
|
||||
getFrpAgentRouteVersions: (serverId: number, proxyId: number) =>
|
||||
request<import('./types').FrpAgentRouteVersions>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}/versions`),
|
||||
getFrpAgentRouteVersion: (serverId: number, proxyId: number, name: string) =>
|
||||
request<import('./types').FrpAgentRouteVersion>(
|
||||
`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}/versions/${encodeURIComponent(name)}`,
|
||||
),
|
||||
rollbackFrpAgentRouteVersion: (serverId: number, proxyId: number, name: string) =>
|
||||
request<{ ok: boolean; domain: string; name: string }>(
|
||||
`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}/versions/${encodeURIComponent(name)}/rollback`,
|
||||
{ method: 'POST' },
|
||||
),
|
||||
installFrpAgentNginx: (serverId: number) =>
|
||||
request<{ ok: boolean; manager?: string; output?: string; path?: string; error?: string }>(`/api/frpmulti/servers/${serverId}/agent/nginx/install`, { method: 'POST' }),
|
||||
getFrpAgentRouteHealth: (serverId: number) =>
|
||||
|
||||
+47
-1
@@ -237,7 +237,7 @@ export interface SystemLogEntry {
|
||||
}
|
||||
|
||||
export type NotifyType = '' | 'email' | 'webhook' | 'telegram'
|
||||
export type WebhookProvider = 'bark' | 'ntfy' | 'gotify' | 'custom'
|
||||
export type WebhookProvider = 'bark' | 'ntfy' | 'gotify' | 'wecom' | 'dingtalk' | 'feishu' | 'custom'
|
||||
|
||||
export interface NotifyEmailConfig {
|
||||
host: string
|
||||
@@ -894,3 +894,49 @@ export interface GlobalNginxView {
|
||||
content: string
|
||||
backups: NginxBackupEntry[]
|
||||
}
|
||||
|
||||
// 配置版本(本机主配置与公网 vhost 共用同一形状)
|
||||
export interface NginxConfigVersionEntry {
|
||||
name: string
|
||||
size: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface NginxConfigVersionsView {
|
||||
message?: string
|
||||
content: string
|
||||
versions: NginxConfigVersionEntry[]
|
||||
}
|
||||
|
||||
export interface NginxConfigVersionView {
|
||||
name: string
|
||||
content: string
|
||||
}
|
||||
|
||||
// 公网 VPS 上某个域名 vhost 的版本历史(需 agent ≥ 0.12.2)
|
||||
export interface FrpAgentRouteVersions {
|
||||
domain: string
|
||||
content: string
|
||||
versions: NginxConfigVersionEntry[]
|
||||
}
|
||||
|
||||
export interface FrpAgentRouteVersion {
|
||||
domain: string
|
||||
name: string
|
||||
content: string
|
||||
}
|
||||
|
||||
// API Token:明文只在创建响应里出现一次
|
||||
// (令牌本身不含密钥材料,是随机串的 SHA-256 存库)
|
||||
export interface ApiToken {
|
||||
id: number
|
||||
name: string
|
||||
scope: 'read' | 'write'
|
||||
expires_at?: string
|
||||
last_used_at?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface CreatedApiToken extends ApiToken {
|
||||
token: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="config-diff">
|
||||
<div class="config-diff__head">
|
||||
<span class="config-diff__range">{{ fromLabel }} → {{ toLabel }}</span>
|
||||
<span class="config-diff__stats">
|
||||
<span class="config-diff__add">+{{ stats.added }}</span>
|
||||
<span class="config-diff__del">−{{ stats.removed }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="config-diff__body">
|
||||
<div
|
||||
v-for="(line, index) in lines"
|
||||
:key="index"
|
||||
class="config-diff__row"
|
||||
:class="`config-diff__row--${line.kind}`"
|
||||
>
|
||||
<span class="config-diff__sign">{{ sign(line.kind) }}</span>
|
||||
<span class="config-diff__text">{{ line.text || ' ' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { diffLines, diffStats, type DiffLine } from '../utils/diff'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
fromText: string
|
||||
toText: string
|
||||
fromLabel?: string
|
||||
toLabel?: string
|
||||
}>(),
|
||||
{ fromLabel: '当前', toLabel: '所选版本' },
|
||||
)
|
||||
|
||||
const lines = computed<DiffLine[]>(() => diffLines(props.fromText, props.toText))
|
||||
const stats = computed(() => diffStats(lines.value))
|
||||
|
||||
function sign(kind: DiffLine['kind']): string {
|
||||
if (kind === 'add') return '+'
|
||||
if (kind === 'del') return '−'
|
||||
return ' '
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.config-diff { border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); overflow: hidden; }
|
||||
.config-diff__head {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-3);
|
||||
padding: 6px var(--fonu-space-3); border-bottom: 1px solid var(--fonu-border);
|
||||
background: var(--fonu-bg-muted); font-size: 12px; color: var(--fonu-text-secondary);
|
||||
}
|
||||
.config-diff__stats { display: flex; gap: 8px; font-family: var(--fonu-mono); }
|
||||
.config-diff__add { color: var(--fonu-brand-text); }
|
||||
.config-diff__del { color: var(--fonu-error); }
|
||||
.config-diff__body { max-height: 420px; overflow: auto; background: var(--fonu-surface); }
|
||||
.config-diff__row {
|
||||
display: flex; gap: 8px; padding: 1px 10px;
|
||||
font-family: var(--fonu-mono); font-size: 12px; line-height: 1.6;
|
||||
}
|
||||
.config-diff__row--add { background: var(--fonu-brand-soft); }
|
||||
.config-diff__row--del { background: var(--fonu-error-soft); }
|
||||
.config-diff__sign { flex: none; width: 10px; color: var(--fonu-text-muted); }
|
||||
.config-diff__row--add .config-diff__sign { color: var(--fonu-brand-text); }
|
||||
.config-diff__row--del .config-diff__sign { color: var(--fonu-error); }
|
||||
.config-diff__text { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
</style>
|
||||
@@ -3,7 +3,7 @@ export interface NotifyEventOption {
|
||||
label: string
|
||||
hint?: string
|
||||
hasThreshold?: boolean
|
||||
thresholdKey?: 'ip_frequent' | 'login_failure'
|
||||
thresholdKey?: 'ip_frequent' | 'login_failure' | 'resource' | 'tunnel_fail'
|
||||
}
|
||||
|
||||
export interface NotifyEventGroup {
|
||||
@@ -71,6 +71,20 @@ export const NOTIFY_EVENT_GROUPS: NotifyEventGroup[] = [
|
||||
label: 'Nginx 重载失败',
|
||||
hint: '保存反代或证书后 Nginx 未能成功重载',
|
||||
},
|
||||
{
|
||||
key: 'on_resource_threshold',
|
||||
label: '资源占用过高',
|
||||
hint: 'CPU / 内存 / 磁盘持续超过阈值,同一项每 30 分钟最多提醒一次',
|
||||
hasThreshold: true,
|
||||
thresholdKey: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'on_tunnel_down',
|
||||
label: '隧道连续不通',
|
||||
hint: '连续多次巡检未在 frps 上注册,恢复时再通知一次',
|
||||
hasThreshold: true,
|
||||
thresholdKey: 'tunnel_fail',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -79,3 +93,7 @@ export const DEFAULT_IP_FREQUENT_THRESHOLD = 100
|
||||
export const DEFAULT_IP_FREQUENT_WINDOW_SEC = 60
|
||||
export const DEFAULT_LOGIN_FAILURE_THRESHOLD = 5
|
||||
export const DEFAULT_LOGIN_FAILURE_WINDOW_SEC = 300
|
||||
export const DEFAULT_CPU_THRESHOLD = 90
|
||||
export const DEFAULT_MEMORY_THRESHOLD = 90
|
||||
export const DEFAULT_DISK_THRESHOLD = 85
|
||||
export const DEFAULT_TUNNEL_FAIL_THRESHOLD = 3
|
||||
|
||||
@@ -11,9 +11,15 @@ export const WEBHOOK_PROVIDER_OPTIONS: { label: string; value: WebhookProvider }
|
||||
{ label: 'Bark', value: 'bark' },
|
||||
{ label: 'ntfy', value: 'ntfy' },
|
||||
{ label: 'Gotify', value: 'gotify' },
|
||||
{ label: '企业微信机器人', value: 'wecom' },
|
||||
{ label: '钉钉机器人', value: 'dingtalk' },
|
||||
{ label: '飞书机器人', value: 'feishu' },
|
||||
{ label: '自定义 Webhook', value: 'custom' },
|
||||
]
|
||||
|
||||
export const DEFAULT_BARK_SERVER = 'https://api.day.app'
|
||||
export const DEFAULT_NTFY_SERVER = 'https://ntfy.sh'
|
||||
export const DEFAULT_WECOM_SERVER = 'https://qyapi.weixin.qq.com'
|
||||
export const DEFAULT_DINGTALK_SERVER = 'https://oapi.dingtalk.com'
|
||||
export const DEFAULT_FEISHU_SERVER = 'https://open.feishu.cn'
|
||||
export const MASKED_SECRET = '********'
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
--fonu-disabled: #94a3b8;
|
||||
--fonu-info: #3b82f6;
|
||||
--fonu-info-soft: rgba(59, 130, 246, 0.1);
|
||||
--fonu-error-soft: rgba(239, 68, 68, 0.1);
|
||||
|
||||
--fonu-bg: #f8fafc;
|
||||
--fonu-bg-muted: #f3f4f6;
|
||||
@@ -47,6 +48,7 @@ html.dark,
|
||||
--fonu-brand-soft: rgba(16, 185, 129, 0.18);
|
||||
--fonu-brand-text: #6ee7b7;
|
||||
--fonu-info-soft: rgba(59, 130, 246, 0.16);
|
||||
--fonu-error-soft: rgba(239, 68, 68, 0.16);
|
||||
|
||||
--fonu-bg: #0f172a;
|
||||
--fonu-bg-muted: #1e293b;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// 极简行级差异:Nginx 配置通常只有几十行,用 LCS 就够,不为此引入第三方 diff 依赖。
|
||||
|
||||
export interface DiffLine {
|
||||
kind: 'same' | 'add' | 'del'
|
||||
text: string
|
||||
oldLine?: number
|
||||
newLine?: number
|
||||
}
|
||||
|
||||
// maxDiffCells 限制 LCS 表大小:超长文本(例如整份日志误粘进来)退化为「整体替换」,
|
||||
// 避免 O(n*m) 的表把页面卡死。
|
||||
const maxDiffCells = 250_000
|
||||
|
||||
export function diffLines(fromText: string, toText: string): DiffLine[] {
|
||||
const from = splitLines(fromText)
|
||||
const to = splitLines(toText)
|
||||
|
||||
if (from.length * to.length > maxDiffCells) {
|
||||
return [
|
||||
...from.map((text, index) => ({ kind: 'del' as const, text, oldLine: index + 1 })),
|
||||
...to.map((text, index) => ({ kind: 'add' as const, text, newLine: index + 1 })),
|
||||
]
|
||||
}
|
||||
|
||||
// dp[i][j] = from[i..] 与 to[j..] 的最长公共子序列长度
|
||||
const dp: number[][] = Array.from({ length: from.length + 1 }, () => new Array<number>(to.length + 1).fill(0))
|
||||
for (let i = from.length - 1; i >= 0; i -= 1) {
|
||||
for (let j = to.length - 1; j >= 0; j -= 1) {
|
||||
dp[i][j] = from[i] === to[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1])
|
||||
}
|
||||
}
|
||||
|
||||
const out: DiffLine[] = []
|
||||
let i = 0
|
||||
let j = 0
|
||||
while (i < from.length && j < to.length) {
|
||||
if (from[i] === to[j]) {
|
||||
out.push({ kind: 'same', text: from[i], oldLine: i + 1, newLine: j + 1 })
|
||||
i += 1
|
||||
j += 1
|
||||
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||
out.push({ kind: 'del', text: from[i], oldLine: i + 1 })
|
||||
i += 1
|
||||
} else {
|
||||
out.push({ kind: 'add', text: to[j], newLine: j + 1 })
|
||||
j += 1
|
||||
}
|
||||
}
|
||||
while (i < from.length) {
|
||||
out.push({ kind: 'del', text: from[i], oldLine: i + 1 })
|
||||
i += 1
|
||||
}
|
||||
while (j < to.length) {
|
||||
out.push({ kind: 'add', text: to[j], newLine: j + 1 })
|
||||
j += 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function diffStats(lines: DiffLine[]): { added: number; removed: number } {
|
||||
let added = 0
|
||||
let removed = 0
|
||||
for (const line of lines) {
|
||||
if (line.kind === 'add') added += 1
|
||||
else if (line.kind === 'del') removed += 1
|
||||
}
|
||||
return { added, removed }
|
||||
}
|
||||
|
||||
function splitLines(text: string): string[] {
|
||||
if (text === '') return []
|
||||
return text.replace(/\r\n/g, '\n').replace(/\n+$/, '').split('\n')
|
||||
}
|
||||
@@ -340,7 +340,7 @@
|
||||
</div>
|
||||
</n-modal>
|
||||
|
||||
<!-- 反代配置查看 -->
|
||||
<!-- 反代配置查看 / 版本对比与回滚 -->
|
||||
<n-modal v-model:show="confModal" :mask-closable="true">
|
||||
<div class="proxy-modal">
|
||||
<div class="proxy-modal__header">
|
||||
@@ -353,7 +353,49 @@
|
||||
<span class="mono">fonu-{{ confDomain }}.conf</span>)。请勿在 VPS 上手工修改:
|
||||
下次部署会被重新生成覆盖,改动请到「部署设置」或穿透规则里调整。
|
||||
</p>
|
||||
<pre class="code-block conf-view">{{ confContent || '加载中…' }}</pre>
|
||||
<div class="conf-layout">
|
||||
<aside class="conf-versions">
|
||||
<div class="conf-versions__head">
|
||||
<span>历史版本</span>
|
||||
<span class="conf-versions__count">{{ routeVersions.length }} / 最近 10 次</span>
|
||||
</div>
|
||||
<p v-if="versionError" class="conf-versions__empty conf-versions__empty--error">{{ versionError }}</p>
|
||||
<p v-else-if="routeVersions.length === 0" class="conf-versions__empty">暂无历史版本(本次部署是第一个版本)</p>
|
||||
<ul v-else class="conf-versions__list">
|
||||
<li
|
||||
v-for="version in routeVersions"
|
||||
:key="version.name"
|
||||
class="conf-versions__item"
|
||||
:class="{ 'conf-versions__item--active': selectedVersionName === version.name }"
|
||||
@click="selectVersion(version)"
|
||||
>
|
||||
<span class="mono conf-versions__time">{{ formatDate(version.created_at) }}</span>
|
||||
<span class="conf-versions__size">{{ formatBytes(version.size) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
<div class="conf-body">
|
||||
<ConfigDiffView
|
||||
v-if="selectedVersionName"
|
||||
:from-text="confContent"
|
||||
:to-text="selectedVersionContent"
|
||||
from-label="当前配置"
|
||||
:to-label="`版本 ${formatDate(versionCreatedAt(selectedVersionName))}`"
|
||||
/>
|
||||
<pre v-else class="code-block conf-view">{{ confContent || '加载中…' }}</pre>
|
||||
<div v-if="selectedVersionName" class="conf-actions">
|
||||
<span class="conf-actions__hint">回滚前会先做语法校验与 nginx -t,校验不过不会覆盖当前配置。</span>
|
||||
<n-popconfirm @positive-click="rollbackToVersion">
|
||||
<template #trigger>
|
||||
<n-button size="small" type="warning" :loading="rollingBack" :disabled="versionLoading">
|
||||
回滚到该版本
|
||||
</n-button>
|
||||
</template>
|
||||
确定把 <span class="mono">{{ confDomain }}</span> 的 vhost 回滚到该版本?
|
||||
</n-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<n-button :disabled="!confContent" @click="copyConf">复制</n-button>
|
||||
@@ -371,6 +413,7 @@ import {
|
||||
CheckmarkCircleOutline, CopyOutline, InformationCircleOutline, LayersOutline, OpenOutline, PulseOutline, RefreshOutline, SwapHorizontalOutline,
|
||||
} from '@vicons/ionicons5'
|
||||
import EmptyState from '../components/EmptyState.vue'
|
||||
import ConfigDiffView from '../components/ConfigDiffView.vue'
|
||||
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
|
||||
import FonuCard from '../components/FonuCard.vue'
|
||||
import LoadError from '../components/LoadError.vue'
|
||||
@@ -379,7 +422,7 @@ import StatusBadge from '../components/StatusBadge.vue'
|
||||
import { api } from '../api/client'
|
||||
import type {
|
||||
CertificateRecord, FrpAgentRouteDetail, FrpAgentRouteHealth, FrpAgentStatus, FrpProxy, FrpProxyTraffic,
|
||||
FrpRouteDriftReport, FrpServer,
|
||||
FrpRouteDriftReport, FrpServer, NginxConfigVersionEntry,
|
||||
} from '../api/types'
|
||||
import { formatBytes, formatDate, formatRate, formatRelativeTime } from '../utils/format'
|
||||
import { copyText } from '../utils/clipboard'
|
||||
@@ -430,6 +473,13 @@ const settingsForm = ref({
|
||||
const confModal = ref(false)
|
||||
const confDomain = ref('')
|
||||
const confContent = ref('')
|
||||
const confProxyId = ref<number | null>(null)
|
||||
const routeVersions = ref<NginxConfigVersionEntry[]>([])
|
||||
const selectedVersionName = ref('')
|
||||
const selectedVersionContent = ref('')
|
||||
const versionLoading = ref(false)
|
||||
const versionError = ref('')
|
||||
const rollingBack = ref(false)
|
||||
|
||||
const server = computed(() => servers.value.find((item) => item.id === selectedId.value) ?? null)
|
||||
const agentStatusOk = computed(() => statusLoaded.value && agentStatus.value !== null)
|
||||
@@ -840,16 +890,74 @@ async function viewConf(candidate: FrpProxy) {
|
||||
if (!current) return
|
||||
if (busy.value) return
|
||||
busy.value = `conf:${candidate.id}`
|
||||
confProxyId.value = candidate.id
|
||||
try {
|
||||
const conf = await api.getFrpAgentRouteConf(current.id, candidate.id)
|
||||
confDomain.value = conf.domain
|
||||
confContent.value = conf.content
|
||||
confModal.value = true
|
||||
await loadRouteVersions(candidate.id)
|
||||
} catch (error) {
|
||||
loadErrorMsg.value = `读取配置失败 —— ${error instanceof Error ? error.message : '未知错误'}`
|
||||
} finally { busy.value = null }
|
||||
}
|
||||
|
||||
// 版本历史读不到不影响「查看配置」本身:只在弹窗内提示(老版本 agent 没有这个端点)
|
||||
async function loadRouteVersions(proxyId: number) {
|
||||
const current = server.value
|
||||
if (!current) return
|
||||
routeVersions.value = []
|
||||
selectedVersionName.value = ''
|
||||
selectedVersionContent.value = ''
|
||||
versionError.value = ''
|
||||
try {
|
||||
const result = await api.getFrpAgentRouteVersions(current.id, proxyId)
|
||||
routeVersions.value = result.versions ?? []
|
||||
} catch (error) {
|
||||
versionError.value = error instanceof Error ? error.message : '读取版本历史失败'
|
||||
}
|
||||
}
|
||||
|
||||
// 点已选中的版本 = 取消对比,回到当前配置
|
||||
async function selectVersion(version: NginxConfigVersionEntry) {
|
||||
const current = server.value
|
||||
if (!current || confProxyId.value === null || versionLoading.value) return
|
||||
if (selectedVersionName.value === version.name) {
|
||||
selectedVersionName.value = ''
|
||||
selectedVersionContent.value = ''
|
||||
return
|
||||
}
|
||||
versionLoading.value = true
|
||||
try {
|
||||
const result = await api.getFrpAgentRouteVersion(current.id, confProxyId.value, version.name)
|
||||
selectedVersionName.value = version.name
|
||||
selectedVersionContent.value = result.content
|
||||
} catch (error) {
|
||||
message.error(`读取该版本失败:${error instanceof Error ? error.message : '未知错误'}`)
|
||||
} finally { versionLoading.value = false }
|
||||
}
|
||||
|
||||
async function rollbackToVersion() {
|
||||
const current = server.value
|
||||
if (!current || confProxyId.value === null || !selectedVersionName.value) return
|
||||
rollingBack.value = true
|
||||
try {
|
||||
await api.rollbackFrpAgentRouteVersion(current.id, confProxyId.value, selectedVersionName.value)
|
||||
message.success('已回滚到所选版本,Nginx 已重载')
|
||||
const conf = await api.getFrpAgentRouteConf(current.id, confProxyId.value)
|
||||
confContent.value = conf.content
|
||||
// 回滚会新增一个版本,且可能改变漂移状态,两边都要重读
|
||||
await loadRouteVersions(confProxyId.value)
|
||||
await refreshAll()
|
||||
} catch (error) {
|
||||
message.error(`回滚失败:${error instanceof Error ? error.message : '未知错误'}`)
|
||||
} finally { rollingBack.value = false }
|
||||
}
|
||||
|
||||
function versionCreatedAt(name: string): string {
|
||||
return routeVersions.value.find((item) => item.name === name)?.created_at ?? ''
|
||||
}
|
||||
|
||||
async function copyConf() {
|
||||
if (await copyText(confContent.value)) message.success('已复制反代配置')
|
||||
else message.error('复制失败,请手动选择文本复制')
|
||||
@@ -979,6 +1087,30 @@ onMounted(() => { void load() })
|
||||
.conf-scroll { padding: var(--fonu-space-4) var(--fonu-space-5) 0; }
|
||||
.conf-scroll .field-hint { margin-bottom: var(--fonu-space-3); line-height: 1.65; }
|
||||
.conf-view { max-height: 62vh; }
|
||||
/* 版本面板 + 差异区:左侧固定宽度列表,右侧自适应(与弹窗 900px 宽度对齐) */
|
||||
.conf-layout { display: grid; grid-template-columns: 208px minmax(0, 1fr); gap: var(--fonu-space-4); }
|
||||
.conf-versions { border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); overflow: hidden; }
|
||||
.conf-versions__head {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
padding: 6px var(--fonu-space-3); border-bottom: 1px solid var(--fonu-border);
|
||||
background: var(--fonu-bg-muted); font-size: 12px; color: var(--fonu-text-secondary);
|
||||
}
|
||||
.conf-versions__count { color: var(--fonu-text-muted); }
|
||||
.conf-versions__empty { padding: var(--fonu-space-3); font-size: 12px; color: var(--fonu-text-muted); line-height: 1.6; }
|
||||
.conf-versions__empty--error { color: var(--fonu-error); }
|
||||
.conf-versions__list { max-height: 52vh; overflow: auto; margin: 0; padding: 0; list-style: none; }
|
||||
.conf-versions__item {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
padding: 6px var(--fonu-space-3); border-bottom: 1px solid var(--fonu-border);
|
||||
font-size: 12px; color: var(--fonu-text-secondary); cursor: pointer;
|
||||
}
|
||||
.conf-versions__item:hover { background: var(--fonu-bg-muted); }
|
||||
.conf-versions__item--active { background: var(--fonu-info-soft); color: var(--fonu-text); }
|
||||
.conf-versions__time { font-size: 12px; }
|
||||
.conf-versions__size { flex: none; color: var(--fonu-text-muted); }
|
||||
.conf-body { min-width: 0; display: grid; gap: var(--fonu-space-3); align-content: start; }
|
||||
.conf-actions { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-3); }
|
||||
.conf-actions__hint { font-size: 12px; color: var(--fonu-text-muted); line-height: 1.6; }
|
||||
.form-hint { color: var(--fonu-text-secondary); font-size: 12.5px; }
|
||||
.mono { font-family: var(--fonu-mono); font-size: 13px; }
|
||||
.mono-wrap { white-space: normal; word-break: break-all; }
|
||||
|
||||
@@ -164,7 +164,11 @@
|
||||
<div v-else-if="notifyType === 'webhook'" class="settings-fields settings-fields--compact">
|
||||
<div class="settings-field">
|
||||
<div class="settings-field__label">Webhook 预设</div>
|
||||
<n-select v-model:value="notifyWebhook.provider" :options="WEBHOOK_PROVIDER_OPTIONS" />
|
||||
<n-select
|
||||
v-model:value="notifyWebhook.provider"
|
||||
:options="WEBHOOK_PROVIDER_OPTIONS"
|
||||
@update:value="onWebhookProviderChange"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="notifyWebhook.provider === 'bark'">
|
||||
<div class="settings-field">
|
||||
@@ -206,6 +210,46 @@
|
||||
placeholder="Gotify App Token"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="notifyWebhook.provider === 'wecom'">
|
||||
<div class="settings-field">
|
||||
<div class="settings-field__label">企业微信服务地址</div>
|
||||
<n-input v-model:value="notifyWebhook.server" :placeholder="DEFAULT_WECOM_SERVER" />
|
||||
</div>
|
||||
<ConfiguredSecretField
|
||||
v-model="notifyWebhookKey"
|
||||
label="机器人 Key"
|
||||
:configured="notifyWebhook.has_secret"
|
||||
placeholder="Webhook 地址里 key= 后面的部分"
|
||||
/>
|
||||
<p class="field-hint">群机器人 → 添加机器人 → 复制 Webhook 地址,取其中 key= 之后的部分。</p>
|
||||
</template>
|
||||
<template v-else-if="notifyWebhook.provider === 'dingtalk'">
|
||||
<div class="settings-field">
|
||||
<div class="settings-field__label">钉钉服务地址</div>
|
||||
<n-input v-model:value="notifyWebhook.server" :placeholder="DEFAULT_DINGTALK_SERVER" />
|
||||
</div>
|
||||
<ConfiguredSecretField
|
||||
v-model="notifyWebhookKey"
|
||||
label="Access Token"
|
||||
:configured="notifyWebhook.has_secret"
|
||||
placeholder="access_token 参数值"
|
||||
/>
|
||||
<p class="field-hint">
|
||||
安全设置选「自定义关键词」并填 <span class="mono">Fonu</span>:消息正文固定以「Fonu · 」开头。加签方式暂不支持。
|
||||
</p>
|
||||
</template>
|
||||
<template v-else-if="notifyWebhook.provider === 'feishu'">
|
||||
<div class="settings-field">
|
||||
<div class="settings-field__label">飞书服务地址</div>
|
||||
<n-input v-model:value="notifyWebhook.server" :placeholder="DEFAULT_FEISHU_SERVER" />
|
||||
</div>
|
||||
<ConfiguredSecretField
|
||||
v-model="notifyWebhookKey"
|
||||
label="机器人 Key"
|
||||
:configured="notifyWebhook.has_secret"
|
||||
placeholder="Webhook 地址 /hook/ 后面的部分"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="settings-field">
|
||||
<div class="settings-field__label">Webhook URL</div>
|
||||
@@ -270,6 +314,26 @@
|
||||
<n-input-number v-model:value="notifyLoginFailureWindowSec" :min="10" :max="3600" class="settings-field__input" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="event.thresholdKey === 'resource'">
|
||||
<div class="settings-field">
|
||||
<label class="settings-field__label">CPU 阈值(%)</label>
|
||||
<n-input-number v-model:value="notifyCPUThreshold" :min="1" :max="100" class="settings-field__input" />
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label class="settings-field__label">内存阈值(%)</label>
|
||||
<n-input-number v-model:value="notifyMemoryThreshold" :min="1" :max="100" class="settings-field__input" />
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label class="settings-field__label">磁盘阈值(%)</label>
|
||||
<n-input-number v-model:value="notifyDiskThreshold" :min="1" :max="100" class="settings-field__input" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="event.thresholdKey === 'tunnel_fail'">
|
||||
<div class="settings-field">
|
||||
<label class="settings-field__label">连续失败次数</label>
|
||||
<n-input-number v-model:value="notifyTunnelFailThreshold" :min="1" :max="20" class="settings-field__input" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -421,6 +485,50 @@
|
||||
class="nginx-framework__editor"
|
||||
/>
|
||||
</details>
|
||||
<details class="nginx-framework">
|
||||
<summary>主配置版本与回滚({{ configVersions.length }} 个历史版本)</summary>
|
||||
<p class="field-hint">
|
||||
保存规则或设置时会重新生成 <code>nginx.conf</code>,这里保留最近 10 次内容有变化的版本。
|
||||
若配置被外部改动导致 Nginx 起不来,可直接回滚(回滚前先校验语法与 nginx -t,不过关不会覆盖当前配置)。
|
||||
</p>
|
||||
<p v-if="configVersionsError" class="config-versions__error">{{ configVersionsError }}</p>
|
||||
<div class="config-versions">
|
||||
<div class="config-versions__list">
|
||||
<div
|
||||
v-for="version in configVersions"
|
||||
:key="version.name"
|
||||
class="config-versions__item"
|
||||
:class="{ 'config-versions__item--active': selectedConfigVersion === version.name }"
|
||||
@click="selectConfigVersion(version)"
|
||||
>
|
||||
<span class="mono">{{ formatDate(version.created_at) }}</span>
|
||||
<span class="config-versions__size">{{ formatBytes(version.size) }}</span>
|
||||
</div>
|
||||
<p v-if="configVersions.length === 0" class="config-versions__empty">暂无历史版本</p>
|
||||
</div>
|
||||
<div class="config-versions__body">
|
||||
<ConfigDiffView
|
||||
v-if="selectedConfigVersion"
|
||||
:from-text="configCurrentContent"
|
||||
:to-text="selectedConfigVersionContent"
|
||||
from-label="当前主配置"
|
||||
:to-label="`版本 ${formatDate(configVersionCreatedAt(selectedConfigVersion))}`"
|
||||
/>
|
||||
<NginxCodeEditor v-else :model-value="configCurrentContent" readonly min-height="200px" />
|
||||
<div v-if="selectedConfigVersion" class="config-versions__actions">
|
||||
<span class="config-versions__hint">回滚会先校验该版本,通过后才写回并重载 Nginx。</span>
|
||||
<n-popconfirm @positive-click="rollbackConfigVersion">
|
||||
<template #trigger>
|
||||
<n-button size="small" type="warning" :loading="configVersionBusy">
|
||||
回滚到该版本
|
||||
</n-button>
|
||||
</template>
|
||||
确定把主配置回滚到该版本?
|
||||
</n-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<div class="log-panel-head global-nginx-head">
|
||||
<div class="nginx-mode-toggle">
|
||||
<n-button
|
||||
@@ -487,6 +595,68 @@
|
||||
<n-alert type="warning" :bordered="false" class="global-nginx-warn">
|
||||
小白勿碰。若保存后 Nginx 启动失败,请点「恢复自动生成」或「回滚」还原配置。
|
||||
</n-alert>
|
||||
</FonuCard>
|
||||
<FonuCard title="API Token" subtitle="给脚本、HomeAssistant、手机快捷指令调用">
|
||||
<p class="field-hint">
|
||||
浏览器之外的调用方拿不到会话 cookie,可在这里签发令牌,用
|
||||
<code>Authorization: Bearer <token></code> 调用接口。明文只在创建时显示一次。
|
||||
</p>
|
||||
<n-alert v-if="createdToken" type="success" :bordered="false" class="token-created">
|
||||
<div class="token-created__head">
|
||||
<strong>令牌已创建,请立即复制</strong>
|
||||
<span>关闭后无法再次查看明文,只能撤销重建。</span>
|
||||
</div>
|
||||
<div class="token-created__row">
|
||||
<code class="token-created__value">{{ createdToken }}</code>
|
||||
<n-button size="small" @click="copyCreatedToken">复制</n-button>
|
||||
</div>
|
||||
</n-alert>
|
||||
|
||||
<div class="settings-fields settings-fields--compact token-create">
|
||||
<div class="settings-field">
|
||||
<label class="settings-field__label">名称</label>
|
||||
<n-input v-model:value="tokenForm.name" placeholder="例如 homeassistant" />
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label class="settings-field__label">权限</label>
|
||||
<n-select v-model:value="tokenForm.scope" :options="tokenScopeOptions" />
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label class="settings-field__label">有效期</label>
|
||||
<n-select v-model:value="tokenForm.expiresInDays" :options="tokenExpiryOptions" />
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label class="settings-field__label">新建</label>
|
||||
<n-button type="primary" :loading="creatingToken" :disabled="!tokenForm.name.trim()" @click="createToken">
|
||||
创建令牌
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LoadError v-if="tokenError" :message="tokenError" />
|
||||
<div v-if="apiTokens.length > 0" class="token-list">
|
||||
<div v-for="token in apiTokens" :key="token.id" class="token-row">
|
||||
<div class="token-row__main">
|
||||
<span class="token-row__name">{{ token.name }}</span>
|
||||
<StatusBadge
|
||||
:text="token.scope === 'write' ? '读写' : '只读'"
|
||||
:kind="token.scope === 'write' ? 'warning' : 'disabled'"
|
||||
/>
|
||||
</div>
|
||||
<div class="token-row__meta">
|
||||
<span>创建 {{ formatDate(token.created_at) }}</span>
|
||||
<span>{{ token.last_used_at ? `最近使用 ${formatDate(token.last_used_at)}` : '尚未使用' }}</span>
|
||||
<span>{{ token.expires_at ? `有效期至 ${formatDate(token.expires_at)}` : '永不过期' }}</span>
|
||||
</div>
|
||||
<n-popconfirm @positive-click="deleteToken(token)">
|
||||
<template #trigger>
|
||||
<n-button size="small" type="warning" quaternary>撤销</n-button>
|
||||
</template>
|
||||
撤销后该令牌立即失效,确定继续?
|
||||
</n-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else-if="!tokenError" class="field-hint">还没有令牌。</p>
|
||||
</FonuCard>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
@@ -526,8 +696,10 @@ import {
|
||||
} from '@vicons/ionicons5'
|
||||
import { api } from '../api/client'
|
||||
import type {
|
||||
ApiToken,
|
||||
ChinaCIDRStatus,
|
||||
GlobalNginxView,
|
||||
NginxConfigVersionEntry,
|
||||
NotifyEmailConfig,
|
||||
NotifyTelegramConfig,
|
||||
NotifyTestPayload,
|
||||
@@ -536,6 +708,7 @@ import type {
|
||||
WebhookProvider,
|
||||
} from '../api/types'
|
||||
import ConfiguredSecretField from '../components/ConfiguredSecretField.vue'
|
||||
import ConfigDiffView from '../components/ConfigDiffView.vue'
|
||||
import FonuCard from '../components/FonuCard.vue'
|
||||
import NginxCodeEditor from '../components/NginxCodeEditor.vue'
|
||||
import LoadError from '../components/LoadError.vue'
|
||||
@@ -543,19 +716,27 @@ import PageHeader from '../components/PageHeader.vue'
|
||||
import StatusBadge from '../components/StatusBadge.vue'
|
||||
import { DEFAULT_PRIVATE_CIDRS, mergeIPList, stripDefaultIPList } from '../constants/ipPolicy'
|
||||
import {
|
||||
DEFAULT_CPU_THRESHOLD,
|
||||
DEFAULT_DISK_THRESHOLD,
|
||||
DEFAULT_LOGIN_FAILURE_THRESHOLD,
|
||||
DEFAULT_LOGIN_FAILURE_WINDOW_SEC,
|
||||
DEFAULT_MEMORY_THRESHOLD,
|
||||
DEFAULT_TUNNEL_FAIL_THRESHOLD,
|
||||
NOTIFY_EVENT_GROUPS,
|
||||
} from '../constants/notifyEvents'
|
||||
import {
|
||||
DEFAULT_BARK_SERVER,
|
||||
DEFAULT_DINGTALK_SERVER,
|
||||
DEFAULT_FEISHU_SERVER,
|
||||
DEFAULT_NTFY_SERVER,
|
||||
DEFAULT_WECOM_SERVER,
|
||||
MASKED_SECRET,
|
||||
NOTIFY_TYPE_OPTIONS,
|
||||
WEBHOOK_PROVIDER_OPTIONS,
|
||||
} from '../constants/notifyProviders'
|
||||
import { useTheme } from '../composables/useTheme'
|
||||
import { formatDate, formatRelativeTime } from '../utils/format'
|
||||
import { copyText } from '../utils/clipboard'
|
||||
import { formatBytes, formatDate, formatRelativeTime } from '../utils/format'
|
||||
import type { StatusKind } from '../utils/status'
|
||||
|
||||
const message = useMessage()
|
||||
@@ -584,11 +765,79 @@ const notifyEventFlags = reactive<Record<string, boolean>>({
|
||||
on_ip_frequent_access: false,
|
||||
on_login_failure: false,
|
||||
on_nginx_reload_failure: true,
|
||||
on_resource_threshold: false,
|
||||
on_tunnel_down: false,
|
||||
})
|
||||
const notifyIPFrequentThreshold = ref(100)
|
||||
const notifyIPFrequentWindowSec = ref(60)
|
||||
const notifyLoginFailureThreshold = ref(DEFAULT_LOGIN_FAILURE_THRESHOLD)
|
||||
const notifyLoginFailureWindowSec = ref(DEFAULT_LOGIN_FAILURE_WINDOW_SEC)
|
||||
const notifyCPUThreshold = ref(DEFAULT_CPU_THRESHOLD)
|
||||
const notifyMemoryThreshold = ref(DEFAULT_MEMORY_THRESHOLD)
|
||||
const notifyDiskThreshold = ref(DEFAULT_DISK_THRESHOLD)
|
||||
const notifyTunnelFailThreshold = ref(DEFAULT_TUNNEL_FAIL_THRESHOLD)
|
||||
|
||||
// API Token:明文只在创建响应里回一次,因此单独用一个 ref 暂存展示
|
||||
const apiTokens = ref<ApiToken[]>([])
|
||||
const tokenError = ref('')
|
||||
const creatingToken = ref(false)
|
||||
const createdToken = ref('')
|
||||
const tokenForm = reactive({ name: '', scope: 'read' as 'read' | 'write', expiresInDays: 0 })
|
||||
const tokenScopeOptions = [
|
||||
{ label: '只读(仅 GET)', value: 'read' },
|
||||
{ label: '读写(与会话同权)', value: 'write' },
|
||||
]
|
||||
const tokenExpiryOptions = [
|
||||
{ label: '永不过期', value: 0 },
|
||||
{ label: '30 天', value: 30 },
|
||||
{ label: '90 天', value: 90 },
|
||||
{ label: '365 天', value: 365 },
|
||||
]
|
||||
|
||||
async function loadApiTokens() {
|
||||
try {
|
||||
const result = await api.listApiTokens()
|
||||
apiTokens.value = result.tokens ?? []
|
||||
tokenError.value = ''
|
||||
} catch (error) {
|
||||
tokenError.value = `读取令牌失败 —— ${error instanceof Error ? error.message : '未知错误'}`
|
||||
}
|
||||
}
|
||||
|
||||
async function createToken() {
|
||||
const name = tokenForm.name.trim()
|
||||
if (!name || creatingToken.value) return
|
||||
creatingToken.value = true
|
||||
try {
|
||||
const result = await api.createApiToken({
|
||||
name,
|
||||
scope: tokenForm.scope,
|
||||
expires_in_days: tokenForm.expiresInDays,
|
||||
})
|
||||
createdToken.value = result.token
|
||||
tokenForm.name = ''
|
||||
await loadApiTokens()
|
||||
message.success('令牌已创建,请复制明文')
|
||||
} catch (error) {
|
||||
message.error(`创建令牌失败:${error instanceof Error ? error.message : '未知错误'}`)
|
||||
} finally { creatingToken.value = false }
|
||||
}
|
||||
|
||||
async function copyCreatedToken() {
|
||||
if (await copyText(createdToken.value)) message.success('已复制令牌')
|
||||
else message.error('复制失败,请手动选择文本复制')
|
||||
}
|
||||
|
||||
async function deleteToken(token: ApiToken) {
|
||||
try {
|
||||
await api.deleteApiToken(token.id)
|
||||
if (createdToken.value) createdToken.value = ''
|
||||
await loadApiTokens()
|
||||
message.success('令牌已撤销')
|
||||
} catch (error) {
|
||||
message.error(`撤销失败:${error instanceof Error ? error.message : '未知错误'}`)
|
||||
}
|
||||
}
|
||||
const notifyTesting = ref(false)
|
||||
const notifySMTPPassword = ref('')
|
||||
const notifyWebhookKey = ref('')
|
||||
@@ -609,6 +858,22 @@ const notifyWebhook = reactive({
|
||||
url: '',
|
||||
has_secret: false,
|
||||
})
|
||||
|
||||
function defaultWebhookServer(provider: WebhookProvider): string {
|
||||
switch (provider) {
|
||||
case 'ntfy': return DEFAULT_NTFY_SERVER
|
||||
case 'wecom': return DEFAULT_WECOM_SERVER
|
||||
case 'dingtalk': return DEFAULT_DINGTALK_SERVER
|
||||
case 'feishu': return DEFAULT_FEISHU_SERVER
|
||||
case 'gotify': return ''
|
||||
default: return DEFAULT_BARK_SERVER
|
||||
}
|
||||
}
|
||||
|
||||
// 切换预设时把服务地址换成该预设的默认值:否则会把上一个预设的地址(如 api.day.app)带到新预设上
|
||||
function onWebhookProviderChange(provider: WebhookProvider) {
|
||||
notifyWebhook.server = defaultWebhookServer(provider)
|
||||
}
|
||||
const notifyTelegram = reactive({
|
||||
chat_id: '',
|
||||
proxy_url: '',
|
||||
@@ -639,6 +904,12 @@ const globalNginxServerMode = ref<'auto' | 'custom'>('auto')
|
||||
const globalNginxDirty = ref(false)
|
||||
const globalNginxSaving = ref(false)
|
||||
const globalNginxBackups = ref<{ name: string; created_at: string }[]>([])
|
||||
const configVersions = ref<NginxConfigVersionEntry[]>([])
|
||||
const configCurrentContent = ref('')
|
||||
const selectedConfigVersion = ref('')
|
||||
const selectedConfigVersionContent = ref('')
|
||||
const configVersionsError = ref('')
|
||||
const configVersionBusy = ref(false)
|
||||
|
||||
const globalNginxCustomHint = `# 在此添加 http 块内的自定义指令
|
||||
# 上传大小限制(全局默认 50M,单条规则可在 server 块覆盖)
|
||||
@@ -777,6 +1048,56 @@ async function loadGlobalNginx() {
|
||||
} catch {
|
||||
// optional on load
|
||||
}
|
||||
await loadConfigVersions()
|
||||
}
|
||||
|
||||
// 主配置(nginx.conf)的版本历史:与全局片段是两份文件,分开读、分开回滚
|
||||
async function loadConfigVersions() {
|
||||
try {
|
||||
const view = await api.getNginxConfigVersions()
|
||||
configCurrentContent.value = view.content
|
||||
configVersions.value = view.versions ?? []
|
||||
configVersionsError.value = ''
|
||||
} catch (error) {
|
||||
configVersionsError.value = error instanceof Error ? error.message : '读取主配置版本失败'
|
||||
}
|
||||
}
|
||||
|
||||
// 点已选中的版本 = 取消对比
|
||||
async function selectConfigVersion(version: NginxConfigVersionEntry) {
|
||||
if (configVersionBusy.value) return
|
||||
if (selectedConfigVersion.value === version.name) {
|
||||
selectedConfigVersion.value = ''
|
||||
selectedConfigVersionContent.value = ''
|
||||
return
|
||||
}
|
||||
configVersionBusy.value = true
|
||||
try {
|
||||
const result = await api.getNginxConfigVersion(version.name)
|
||||
selectedConfigVersion.value = version.name
|
||||
selectedConfigVersionContent.value = result.content
|
||||
} catch (error) {
|
||||
message.error(`读取该版本失败:${error instanceof Error ? error.message : '未知错误'}`)
|
||||
} finally { configVersionBusy.value = false }
|
||||
}
|
||||
|
||||
async function rollbackConfigVersion() {
|
||||
if (!selectedConfigVersion.value) return
|
||||
configVersionBusy.value = true
|
||||
try {
|
||||
const view = await api.rollbackNginxConfigVersion(selectedConfigVersion.value)
|
||||
configCurrentContent.value = view.content
|
||||
configVersions.value = view.versions ?? []
|
||||
selectedConfigVersion.value = ''
|
||||
selectedConfigVersionContent.value = ''
|
||||
message.success(view.message || '已回滚到所选版本')
|
||||
} catch (error) {
|
||||
message.error(`回滚失败:${error instanceof Error ? error.message : '未知错误'}`)
|
||||
} finally { configVersionBusy.value = false }
|
||||
}
|
||||
|
||||
function configVersionCreatedAt(name: string): string {
|
||||
return configVersions.value.find((item) => item.name === name)?.created_at ?? ''
|
||||
}
|
||||
|
||||
function setGlobalNginxEditMode(mode: 'auto' | 'custom') {
|
||||
@@ -911,7 +1232,7 @@ function applyNotifySettings(settings: Record<string, string>) {
|
||||
try {
|
||||
const webhook = JSON.parse(settings.notify_webhook_json || '{}') as NotifyWebhookConfig
|
||||
notifyWebhook.provider = webhook.provider ?? 'bark'
|
||||
notifyWebhook.server = webhook.server ?? (webhook.provider === 'ntfy' ? DEFAULT_NTFY_SERVER : DEFAULT_BARK_SERVER)
|
||||
notifyWebhook.server = webhook.server ?? defaultWebhookServer(webhook.provider)
|
||||
notifyWebhook.topic = webhook.topic ?? ''
|
||||
notifyWebhook.url = webhook.url ?? ''
|
||||
notifyWebhook.has_secret = webhook.has_secret ?? false
|
||||
@@ -955,6 +1276,8 @@ function applyNotifySettings(settings: Record<string, string>) {
|
||||
notifyEventFlags.on_ip_frequent_access = flagOptIn('notify_on_ip_frequent_access')
|
||||
notifyEventFlags.on_login_failure = flagOptIn('notify_on_login_failure')
|
||||
notifyEventFlags.on_nginx_reload_failure = flagOn('notify_on_nginx_reload_failure')
|
||||
notifyEventFlags.on_resource_threshold = flagOptIn('notify_on_resource_threshold')
|
||||
notifyEventFlags.on_tunnel_down = flagOptIn('notify_on_tunnel_down')
|
||||
} else {
|
||||
const legacyDDNS = flagOn('notify_on_ddns_error')
|
||||
const legacyCert = flagOn('notify_on_cert_error')
|
||||
@@ -966,11 +1289,17 @@ function applyNotifySettings(settings: Record<string, string>) {
|
||||
notifyEventFlags.on_nginx_reload_failure = true
|
||||
notifyEventFlags.on_ip_frequent_access = false
|
||||
notifyEventFlags.on_login_failure = false
|
||||
notifyEventFlags.on_resource_threshold = false
|
||||
notifyEventFlags.on_tunnel_down = false
|
||||
}
|
||||
notifyIPFrequentThreshold.value = Number(settings.notify_ip_frequent_threshold || 100)
|
||||
notifyIPFrequentWindowSec.value = Number(settings.notify_ip_frequent_window_sec || 60)
|
||||
notifyLoginFailureThreshold.value = Number(settings.notify_login_failure_threshold || DEFAULT_LOGIN_FAILURE_THRESHOLD)
|
||||
notifyLoginFailureWindowSec.value = Number(settings.notify_login_failure_window_sec || DEFAULT_LOGIN_FAILURE_WINDOW_SEC)
|
||||
notifyCPUThreshold.value = Number(settings.notify_cpu_threshold || DEFAULT_CPU_THRESHOLD)
|
||||
notifyMemoryThreshold.value = Number(settings.notify_memory_threshold || DEFAULT_MEMORY_THRESHOLD)
|
||||
notifyDiskThreshold.value = Number(settings.notify_disk_threshold || DEFAULT_DISK_THRESHOLD)
|
||||
notifyTunnelFailThreshold.value = Number(settings.notify_tunnel_fail_threshold || DEFAULT_TUNNEL_FAIL_THRESHOLD)
|
||||
}
|
||||
|
||||
function buildNotifyEmailJSON(): string {
|
||||
@@ -1076,6 +1405,12 @@ function buildSavePayload() {
|
||||
notify_on_ip_frequent_access: notifyEventFlags.on_ip_frequent_access ? '1' : '0',
|
||||
notify_on_login_failure: notifyEventFlags.on_login_failure ? '1' : '0',
|
||||
notify_on_nginx_reload_failure: notifyEventFlags.on_nginx_reload_failure ? '1' : '0',
|
||||
notify_on_resource_threshold: notifyEventFlags.on_resource_threshold ? '1' : '0',
|
||||
notify_on_tunnel_down: notifyEventFlags.on_tunnel_down ? '1' : '0',
|
||||
notify_cpu_threshold: String(notifyCPUThreshold.value || DEFAULT_CPU_THRESHOLD),
|
||||
notify_memory_threshold: String(notifyMemoryThreshold.value || DEFAULT_MEMORY_THRESHOLD),
|
||||
notify_disk_threshold: String(notifyDiskThreshold.value || DEFAULT_DISK_THRESHOLD),
|
||||
notify_tunnel_fail_threshold: String(notifyTunnelFailThreshold.value || DEFAULT_TUNNEL_FAIL_THRESHOLD),
|
||||
notify_ip_frequent_threshold: String(notifyIPFrequentThreshold.value || 100),
|
||||
notify_ip_frequent_window_sec: String(notifyIPFrequentWindowSec.value || 60),
|
||||
notify_login_failure_threshold: String(notifyLoginFailureThreshold.value || DEFAULT_LOGIN_FAILURE_THRESHOLD),
|
||||
@@ -1174,7 +1509,10 @@ async function onRestoreFile({ file }: { file: UploadFileInfo }) {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
onMounted(() => {
|
||||
void load()
|
||||
void loadApiTokens()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -1297,6 +1635,42 @@ onMounted(load)
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* 主配置版本:左侧版本列表 + 右侧当前内容 / 差异 */
|
||||
.config-versions { display: grid; grid-template-columns: 208px minmax(0, 1fr); gap: var(--fonu-space-4); margin-top: 8px; }
|
||||
.config-versions__list { border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); overflow: auto; max-height: 320px; }
|
||||
.config-versions__item {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
padding: 6px var(--fonu-space-3); border-bottom: 1px solid var(--fonu-border);
|
||||
font-size: 12px; color: var(--fonu-text-secondary); cursor: pointer;
|
||||
}
|
||||
.config-versions__item:hover { background: var(--fonu-bg-muted); }
|
||||
.config-versions__item--active { background: var(--fonu-info-soft); color: var(--fonu-text); }
|
||||
.config-versions__size { flex: none; color: var(--fonu-text-muted); }
|
||||
.config-versions__empty { padding: var(--fonu-space-3); font-size: 12px; color: var(--fonu-text-muted); }
|
||||
.config-versions__body { min-width: 0; display: grid; gap: var(--fonu-space-3); align-content: start; }
|
||||
.config-versions__actions { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-3); }
|
||||
.config-versions__hint { font-size: 12px; color: var(--fonu-text-muted); line-height: 1.6; }
|
||||
.config-versions__error { margin: 8px 0 0; font-size: 12px; color: var(--fonu-error); line-height: 1.6; }
|
||||
|
||||
/* API Token:创建区一行放四项,列表一行一项 */
|
||||
/* 必须带上 .settings-fields 提高优先级:那条纵向 flex 规则在样式表里位置更靠后,
|
||||
否则 display 会被它覆盖回 flex-column(子项被推到右侧、整卡变很高) */
|
||||
.settings-fields.token-create {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
.token-created { margin: 12px 0; }
|
||||
.token-created__head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; font-size: 13px; }
|
||||
.token-created__row { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
|
||||
.token-created__value { flex: 1; min-width: 0; font-family: var(--fonu-mono); font-size: 12px; overflow-wrap: anywhere; }
|
||||
.token-list { display: grid; gap: 8px; margin-top: 12px; }
|
||||
.token-row { display: flex; align-items: center; gap: var(--fonu-space-3); padding: 10px var(--fonu-space-3); border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); }
|
||||
.token-row__main { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.token-row__name { font-weight: 600; color: var(--fonu-text); }
|
||||
.token-row__meta { flex: 1; display: flex; gap: var(--fonu-space-3); flex-wrap: wrap; font-size: 12px; color: var(--fonu-text-muted); }
|
||||
|
||||
.global-nginx-head {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user