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:
2026-09-24 20:50:17 +08:00
parent 8f75e99339
commit 862442e77f
42 changed files with 3034 additions and 190 deletions
+36
View File
@@ -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
### 修复
+1 -1
View File
@@ -1 +1 @@
1.2.8
1.2.9
+271
View File
@@ -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`)。
+8 -6
View File
@@ -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()
}
+127
View File
@@ -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
}
+82 -15
View File
@@ -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 运行)。
+40
View File
@@ -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
+130
View File
@@ -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)
}
}
}
+27
View File
@@ -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
+9
View File
@@ -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)
+37 -25
View File
@@ -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:
+72
View File
@@ -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})
}
+26
View File
@@ -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)
+166
View File
@@ -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[:])
}
+118
View File
@@ -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)
}
}
+40 -8
View File
@@ -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.
+119
View File
@@ -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[:])
}
+129
View File
@@ -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)
}
}
+194
View File
@@ -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
}
+71
View File
@@ -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
View File
@@ -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
}
+104
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+90
View File
@@ -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 分支")
}
}
+4
View File
@@ -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
}
+59 -6
View File
@@ -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 == "" {
+70 -5
View File
@@ -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
}
+65 -59
View File
@@ -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 {
+12
View File
@@ -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
View File
@@ -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": {
+28
View File
@@ -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
View File
@@ -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
}
+69
View File
@@ -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>
+19 -1
View File
@@ -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
+6
View File
@@ -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 = '********'
+2
View File
@@ -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;
+73
View File
@@ -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')
}
+135 -3
View File
@@ -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; }
+378 -4
View File
@@ -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 &lt;token&gt;</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;
}