feat: 配置版本历史/对比/回滚、国内 IM 通知渠道、阈值告警与 API Token(v1.2.9)
CI / verify (push) Canceled after 0s

- 配置版本: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)
+12
View File
@@ -68,6 +68,12 @@ func (h *SettingsHandler) Put(w http.ResponseWriter, r *http.Request) {
settings.KeyNotifyIPFrequentWindowSec: true,
settings.KeyNotifyLoginFailureThreshold: true,
settings.KeyNotifyLoginFailureWindowSec: 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,
@@ -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
}
+39
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"
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
@@ -35,9 +48,23 @@ const (
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"`
@@ -80,6 +107,12 @@ type Config struct {
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 {
@@ -109,6 +142,12 @@ type SaveInput struct {
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,
})
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return err
// 企业微信群机器人: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 未配置")
}
req.Header.Set("Content-Type", "application/json")
return doRequest(client, req)
server := normalizeServerURL(cfg.Webhook.Server)
if server == "" {
server = DefaultWecomServer
}
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 == "" {
+65
View File
@@ -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
}
+6
View File
@@ -40,6 +40,12 @@ const (
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"
+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;
}