diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a835dd..c3ca8ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,42 @@ All notable changes to this project will be documented in this file. +## [v1.2.9] - 2026-09-24 + +### 新增 + +- **配置版本历史 / 对比 / 回滚**:主 `nginx.conf` 每次保存前留一份版本(`.versions/`,保留最近 10 次**内容有变化**的改动,内容相同自动去重);设置页「Nginx 全局配置」内可看版本列表、逐行差异并一键回滚——回滚先过语法校验与 `nginx -t`,不过关不覆盖当前配置 +- 公网反代页的「查看配置」弹窗内可看该域名在 VPS 上的历史版本、与当前配置做差异对比并回滚(需 agent ≥ 0.12.2) +- **通知渠道补齐**:新增企业微信机器人、钉钉机器人、飞书机器人(钉钉正文以「Fonu · 」开头,机器人用「自定义关键词 = Fonu」即可命中;加签方式暂不支持) +- **阈值型告警**(默认关闭):CPU / 内存 / 磁盘持续超阈值、隧道连续 N 次未在 frps 上注册时通知,恢复时再通知一次;阈值与开关在设置页「告警事件」里配置 +- **API Token**:新增 `api_tokens` 表与 `Authorization: Bearer ` 认证,供脚本 / 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 ### 修复 diff --git a/VERSION b/VERSION index db6fb4a..9d4f823 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.8 +1.2.9 diff --git a/docs/feature-benchmark-2026-09-24.md b/docs/feature-benchmark-2026-09-24.md new file mode 100644 index 0000000..863f4af --- /dev/null +++ b/docs/feature-benchmark-2026-09-24.md @@ -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` 只保留一份 `.bak`;公网反代页已有「漂移检测」能看出「VPS 上的与记录不一致」,但没有历史可比。 + - 做法:把 `.bak` 升级为 `.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` 加每日备份任务(写 `/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` | 版本存为 `.versions/-<随机 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 `;`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`)。 diff --git a/internal/agent/server.go b/internal/agent/server.go index 0fa0ffb..8cefcef 100644 --- a/internal/agent/server.go +++ b/internal/agent/server.go @@ -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() } diff --git a/internal/agent/versions.go b/internal/agent/versions.go new file mode 100644 index 0000000..997684a --- /dev/null +++ b/internal/agent/versions.go @@ -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 +} diff --git a/internal/api/frpmulti_handler.go b/internal/api/frpmulti_handler.go index 021e459..449f67b 100644 --- a/internal/api/frpmulti_handler.go +++ b/internal/api/frpmulti_handler.go @@ -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 运行)。 diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 986ec91..5a90bd6 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -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 。 +// 令牌分读写两种范围: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 diff --git a/internal/api/middleware_test.go b/internal/api/middleware_test.go new file mode 100644 index 0000000..20cea8f --- /dev/null +++ b/internal/api/middleware_test.go @@ -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) + } + } +} diff --git a/internal/api/nginx_handler.go b/internal/api/nginx_handler.go index 293d61e..54b062b 100644 --- a/internal/api/nginx_handler.go +++ b/internal/api/nginx_handler.go @@ -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 diff --git a/internal/api/router.go b/internal/api/router.go index b487730..9ab5d1c 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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) diff --git a/internal/api/settings_handler.go b/internal/api/settings_handler.go index bca490b..b7e84b0 100644 --- a/internal/api/settings_handler.go +++ b/internal/api/settings_handler.go @@ -39,25 +39,25 @@ func (h *SettingsHandler) Put(w http.ResponseWriter, r *http.Request) { return } allowed := map[string]bool{ - settings.KeyTheme: true, - settings.KeyTimezone: true, - settings.KeyLogRetentionDays: true, - settings.KeyDDNSCheckInterval: true, - settings.KeyCertRenewThreshold: true, - settings.KeyRootDomain: true, - settings.KeyACMEEmail: true, - settings.KeyACMECA: true, - settings.KeyZeroSSLAPIKey: true, - settings.KeyNotifyWebhookURL: true, - settings.KeyNotifyType: true, - settings.KeyNotifyEmailJSON: true, - settings.KeyNotifySMTPPassword: true, - settings.KeyNotifyWebhookJSON: true, - settings.KeyNotifyWebhookSecret: true, - settings.KeyNotifyTelegramJSON: true, - settings.KeyNotifyTelegramToken: true, - settings.KeyNotifyOnDDNSIPChange: true, - settings.KeyNotifyOnDDNSFailure: true, + settings.KeyTheme: true, + settings.KeyTimezone: true, + settings.KeyLogRetentionDays: true, + settings.KeyDDNSCheckInterval: true, + settings.KeyCertRenewThreshold: true, + settings.KeyRootDomain: true, + settings.KeyACMEEmail: true, + settings.KeyACMECA: true, + settings.KeyZeroSSLAPIKey: true, + settings.KeyNotifyWebhookURL: true, + settings.KeyNotifyType: true, + settings.KeyNotifyEmailJSON: true, + settings.KeyNotifySMTPPassword: true, + settings.KeyNotifyWebhookJSON: true, + settings.KeyNotifyWebhookSecret: true, + settings.KeyNotifyTelegramJSON: true, + settings.KeyNotifyTelegramToken: true, + settings.KeyNotifyOnDDNSIPChange: true, + settings.KeyNotifyOnDDNSFailure: true, settings.KeyNotifyOnCertExpiry: true, settings.KeyNotifyOnCertRenewSuccess: true, settings.KeyNotifyOnIPFrequentAccess: true, @@ -68,12 +68,18 @@ func (h *SettingsHandler) Put(w http.ResponseWriter, r *http.Request) { settings.KeyNotifyIPFrequentWindowSec: true, settings.KeyNotifyLoginFailureThreshold: true, settings.KeyNotifyLoginFailureWindowSec: true, - settings.KeyTrustedProxy: true, - settings.KeyGlobalIPBlacklist: true, - settings.KeyGlobalIPWhitelist: true, - settings.KeyChinaCIDRUpdateHours: true, - settings.KeyChinaCIDRSourceV4: true, - settings.KeyChinaCIDRSourceV6: true, + settings.KeyNotifyOnResourceThreshold: true, + settings.KeyNotifyOnTunnelDown: true, + settings.KeyNotifyCPUThreshold: true, + settings.KeyNotifyMemoryThreshold: true, + settings.KeyNotifyDiskThreshold: true, + settings.KeyNotifyTunnelFailThreshold: true, + settings.KeyTrustedProxy: true, + settings.KeyGlobalIPBlacklist: true, + settings.KeyGlobalIPWhitelist: true, + settings.KeyChinaCIDRUpdateHours: true, + settings.KeyChinaCIDRSourceV4: true, + settings.KeyChinaCIDRSourceV6: true, } filtered := map[string]string{} for key, value := range req { @@ -139,6 +145,12 @@ func isNotifySettingKey(key string) bool { settings.KeyNotifyIPFrequentWindowSec, settings.KeyNotifyLoginFailureThreshold, settings.KeyNotifyLoginFailureWindowSec, + settings.KeyNotifyOnResourceThreshold, + settings.KeyNotifyOnTunnelDown, + settings.KeyNotifyCPUThreshold, + settings.KeyNotifyMemoryThreshold, + settings.KeyNotifyDiskThreshold, + settings.KeyNotifyTunnelFailThreshold, settings.KeyNotifyWebhookURL: return true default: diff --git a/internal/api/token_handler.go b/internal/api/token_handler.go new file mode 100644 index 0000000..c3f4cfa --- /dev/null +++ b/internal/api/token_handler.go @@ -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}) +} diff --git a/internal/app/app.go b/internal/app/app.go index 695ca36..641b87c 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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) diff --git a/internal/auth/token.go b/internal/auth/token.go new file mode 100644 index 0000000..fc076d8 --- /dev/null +++ b/internal/auth/token.go @@ -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[:]) +} diff --git a/internal/auth/token_test.go b/internal/auth/token_test.go new file mode 100644 index 0000000..411a58d --- /dev/null +++ b/internal/auth/token_test.go @@ -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) + } +} diff --git a/internal/frp/agent_client.go b/internal/frp/agent_client.go index e9efdad..612d8ba 100644 --- a/internal/frp/agent_client.go +++ b/internal/frp/agent_client.go @@ -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 +} diff --git a/internal/frp/agentbin/fonu-agent-linux-amd64 b/internal/frp/agentbin/fonu-agent-linux-amd64 index a23edfd..d5823a1 100644 Binary files a/internal/frp/agentbin/fonu-agent-linux-amd64 and b/internal/frp/agentbin/fonu-agent-linux-amd64 differ diff --git a/internal/frp/agentbin/fonu-agent-linux-arm64 b/internal/frp/agentbin/fonu-agent-linux-arm64 index 6b6f503..c93131f 100644 Binary files a/internal/frp/agentbin/fonu-agent-linux-arm64 and b/internal/frp/agentbin/fonu-agent-linux-arm64 differ diff --git a/internal/fsutil/fsutil.go b/internal/fsutil/fsutil.go index 67fcd29..b9dff69 100644 --- a/internal/fsutil/fsutil.go +++ b/internal/fsutil/fsutil.go @@ -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 返回某文件的版本目录(.versions),供列表 / 回滚读取。 +func VersionsDir(path string) string { + return path + ".versions" +} + +// BackupVersioned 把现有文件留存为 .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[:]) +} diff --git a/internal/fsutil/fsutil_test.go b/internal/fsutil/fsutil_test.go index c2e4f9d..b08b933 100644 --- a/internal/fsutil/fsutil_test.go +++ b/internal/fsutil/fsutil_test.go @@ -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) + } +} diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go new file mode 100644 index 0000000..2efdb03 --- /dev/null +++ b/internal/monitor/monitor.go @@ -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 +} diff --git a/internal/monitor/monitor_test.go b/internal/monitor/monitor_test.go new file mode 100644 index 0000000..87c023e --- /dev/null +++ b/internal/monitor/monitor_test.go @@ -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) + } +} diff --git a/internal/nginx/nginx.go b/internal/nginx/nginx.go index 947f032..30b46c2 100644 --- a/internal/nginx/nginx.go +++ b/internal/nginx/nginx.go @@ -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 } diff --git a/internal/nginx/versions.go b/internal/nginx/versions.go new file mode 100644 index 0000000..ba01119 --- /dev/null +++ b/internal/nginx/versions.go @@ -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 +} diff --git a/internal/notify/config.go b/internal/notify/config.go index 1b6ffd5..e985aee 100644 --- a/internal/notify/config.go +++ b/internal/notify/config.go @@ -9,16 +9,29 @@ const ( EventIPFrequentAccess = "ip_frequent_access" EventLoginFailure = "login_failure" EventNginxReloadFailure = "nginx_reload_failure" + EventResourceThreshold = "resource_threshold" + EventTunnelDown = "tunnel_down" MaskedSecret = "********" - DefaultBarkServer = "https://api.day.app" - DefaultIPFrequentThreshold = 100 - DefaultIPFrequentWindowSec = 60 - DefaultIPFrequentAlertCooldown = 15 * 60 // seconds - DefaultLoginFailureThreshold = 5 - DefaultLoginFailureWindowSec = 300 + DefaultBarkServer = "https://api.day.app" + DefaultNtfyServer = "https://ntfy.sh" + DefaultWecomServer = "https://qyapi.weixin.qq.com" + DefaultDingtalkServer = "https://oapi.dingtalk.com" + DefaultFeishuServer = "https://open.feishu.cn" + DefaultIPFrequentThreshold = 100 + DefaultIPFrequentWindowSec = 60 + DefaultIPFrequentAlertCooldown = 15 * 60 // seconds + DefaultLoginFailureThreshold = 5 + DefaultLoginFailureWindowSec = 300 DefaultLoginFailureAlertCooldown = 15 * 60 // seconds + DefaultCPUThreshold = 90 + DefaultMemoryThreshold = 90 + DefaultDiskThreshold = 85 + DefaultTunnelFailThreshold = 3 + // DefaultResourceAlertCooldown 是资源类告警的静默期:持续高占用时每半小时提醒一次, + // 期间恢复正常会清掉冷却,再次超标可立即再报。 + DefaultResourceAlertCooldown = 30 * 60 // seconds ) type NotifyType string @@ -32,12 +45,26 @@ const ( type WebhookProvider string const ( - WebhookBark WebhookProvider = "bark" - WebhookNtfy WebhookProvider = "ntfy" - WebhookGotify WebhookProvider = "gotify" - WebhookCustom WebhookProvider = "custom" + WebhookBark WebhookProvider = "bark" + WebhookNtfy WebhookProvider = "ntfy" + WebhookGotify WebhookProvider = "gotify" + WebhookWecom WebhookProvider = "wecom" + WebhookDingtalk WebhookProvider = "dingtalk" + WebhookFeishu WebhookProvider = "feishu" + WebhookCustom WebhookProvider = "custom" ) +// providerUsesKey 表示该预设把凭据放在 Webhook.Key(而不是 WebhookSecret)里。 +// 保存时的「已配置」判定与密钥落库都按这份名单走,新增预设时只需改这一处。 +func providerUsesKey(provider WebhookProvider) bool { + switch provider { + case WebhookBark, WebhookNtfy, WebhookGotify, WebhookWecom, WebhookDingtalk, WebhookFeishu: + return true + default: + return false + } +} + type EmailConfig struct { Host string `json:"host"` Port int `json:"port"` @@ -64,22 +91,28 @@ type TelegramConfig struct { } type Config struct { - Type NotifyType `json:"type"` - Email EmailConfig `json:"email"` - Webhook WebhookConfig `json:"webhook"` + Type NotifyType `json:"type"` + Email EmailConfig `json:"email"` + Webhook WebhookConfig `json:"webhook"` Telegram TelegramConfig `json:"telegram"` - OnDDNSIPChange bool `json:"on_ddns_ip_change"` - OnDDNSFailure bool `json:"on_ddns_failure"` - OnCertExpiry bool `json:"on_cert_expiry"` - OnCertRenewSuccess bool `json:"on_cert_renew_success"` - OnCertRenewFailure bool `json:"on_cert_renew_failure"` - OnIPFrequentAccess bool `json:"on_ip_frequent_access"` - OnLoginFailure bool `json:"on_login_failure"` - OnNginxReloadFailure bool `json:"on_nginx_reload_failure"` - IPFrequentThreshold int `json:"ip_frequent_threshold"` - IPFrequentWindowSec int `json:"ip_frequent_window_sec"` - LoginFailureThreshold int `json:"login_failure_threshold"` - LoginFailureWindowSec int `json:"login_failure_window_sec"` + OnDDNSIPChange bool `json:"on_ddns_ip_change"` + OnDDNSFailure bool `json:"on_ddns_failure"` + OnCertExpiry bool `json:"on_cert_expiry"` + OnCertRenewSuccess bool `json:"on_cert_renew_success"` + OnCertRenewFailure bool `json:"on_cert_renew_failure"` + OnIPFrequentAccess bool `json:"on_ip_frequent_access"` + OnLoginFailure bool `json:"on_login_failure"` + OnNginxReloadFailure bool `json:"on_nginx_reload_failure"` + IPFrequentThreshold int `json:"ip_frequent_threshold"` + IPFrequentWindowSec int `json:"ip_frequent_window_sec"` + LoginFailureThreshold int `json:"login_failure_threshold"` + LoginFailureWindowSec int `json:"login_failure_window_sec"` + OnResourceThreshold bool `json:"on_resource_threshold"` + OnTunnelDown bool `json:"on_tunnel_down"` + CPUThreshold int `json:"cpu_threshold"` + MemoryThreshold int `json:"memory_threshold"` + DiskThreshold int `json:"disk_threshold"` + TunnelFailThreshold int `json:"tunnel_fail_threshold"` } type RuntimeConfig struct { @@ -90,25 +123,31 @@ type RuntimeConfig struct { } type SaveInput struct { - Type NotifyType - Email EmailConfig - SMTPPassword string - Webhook WebhookConfig - WebhookSecret string - Telegram TelegramConfig - TelegramToken string - OnDDNSIPChange bool - OnDDNSFailure bool - OnCertExpiry bool - OnCertRenewSuccess bool - OnCertRenewFailure bool - OnIPFrequentAccess bool - OnLoginFailure bool - OnNginxReloadFailure bool - IPFrequentThreshold int - IPFrequentWindowSec int - LoginFailureThreshold int - LoginFailureWindowSec int + Type NotifyType + Email EmailConfig + SMTPPassword string + Webhook WebhookConfig + WebhookSecret string + Telegram TelegramConfig + TelegramToken string + OnDDNSIPChange bool + OnDDNSFailure bool + OnCertExpiry bool + OnCertRenewSuccess bool + OnCertRenewFailure bool + OnIPFrequentAccess bool + OnLoginFailure bool + OnNginxReloadFailure bool + IPFrequentThreshold int + IPFrequentWindowSec int + LoginFailureThreshold int + LoginFailureWindowSec int + OnResourceThreshold bool + OnTunnelDown bool + CPUThreshold int + MemoryThreshold int + DiskThreshold int + TunnelFailThreshold int } type TestInput struct { diff --git a/internal/notify/push.go b/internal/notify/push.go index 5584ef4..bc5cfa8 100644 --- a/internal/notify/push.go +++ b/internal/notify/push.go @@ -20,6 +20,12 @@ func SendWebhook(ctx context.Context, client *http.Client, cfg RuntimeConfig, co return sendNtfy(ctx, client, cfg, content.PushTitle, content.PlainBody) case WebhookGotify: return sendGotify(ctx, client, cfg, content.PushTitle, content.PlainBody) + case WebhookWecom: + return sendWecom(ctx, client, cfg, content.PushTitle, content.PlainBody) + case WebhookDingtalk: + return sendDingtalk(ctx, client, cfg, content.PushTitle, content.PlainBody) + case WebhookFeishu: + return sendFeishu(ctx, client, cfg, content.PushTitle, content.PlainBody) case WebhookCustom: return sendCustomWebhook(ctx, client, cfg, content) default: @@ -58,7 +64,7 @@ func sendNtfy(ctx context.Context, client *http.Client, cfg RuntimeConfig, title } server := normalizeServerURL(cfg.Webhook.Server) if server == "" { - server = "https://ntfy.sh" + server = DefaultNtfyServer } endpoint := joinURL(server, topic) req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(message)) @@ -83,18 +89,69 @@ func sendGotify(ctx context.Context, client *http.Client, cfg RuntimeConfig, tit return fmt.Errorf("Gotify 服务地址未配置") } endpoint := joinURL(server, "message") + "?token=" + url.QueryEscape(token) - payload := map[string]any{ + return postJSON(ctx, client, endpoint, map[string]any{ "title": title, "message": message, "priority": 5, + }) +} + +// 企业微信群机器人:POST /cgi-bin/webhook/send?key=,markdown 正文 +func sendWecom(ctx context.Context, client *http.Client, cfg RuntimeConfig, title, message string) error { + key := webhookSecret(cfg) + if key == "" { + return fmt.Errorf("企业微信机器人 Key 未配置") } - body, _ := json.Marshal(payload) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) - if err != nil { - return err + server := normalizeServerURL(cfg.Webhook.Server) + if server == "" { + server = DefaultWecomServer } - req.Header.Set("Content-Type", "application/json") - return doRequest(client, req) + endpoint := joinURL(server, "cgi-bin", "webhook", "send") + "?key=" + url.QueryEscape(key) + return postJSON(ctx, client, endpoint, map[string]any{ + "msgtype": "markdown", + "markdown": map[string]string{"content": markdownBody(title, message)}, + }) +} + +// 钉钉群机器人:POST /robot/send?access_token=。 +// 正文固定带 “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/,纯文本消息 +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 } diff --git a/internal/notify/push_test.go b/internal/notify/push_test.go index 8602db7..09f54d0 100644 --- a/internal/notify/push_test.go +++ b/internal/notify/push_test.go @@ -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 分支") + } +} diff --git a/internal/notify/service.go b/internal/notify/service.go index 5cc20a2..af3fa19 100644 --- a/internal/notify/service.go +++ b/internal/notify/service.go @@ -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 } diff --git a/internal/notify/store.go b/internal/notify/store.go index a0eff4d..9f0bf0c 100644 --- a/internal/notify/store.go +++ b/internal/notify/store.go @@ -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 == "" { diff --git a/internal/service/nginx_config.go b/internal/service/nginx_config.go index 04cb23d..40ff329 100644 --- a/internal/service/nginx_config.go +++ b/internal/service/nginx_config.go @@ -25,11 +25,11 @@ type RuleNginxView struct { } type GlobalNginxView struct { - Mode string `json:"mode"` - GeneratedFramework string `json:"generated_framework"` - GeneratedSnippet string `json:"generated_snippet"` - Content string `json:"content"` - Backups []NginxBackupInfo `json:"backups"` + Mode string `json:"mode"` + GeneratedFramework string `json:"generated_framework"` + GeneratedSnippet string `json:"generated_snippet"` + Content string `json:"content"` + Backups []NginxBackupInfo `json:"backups"` } type SaveRuleNginxInput struct { @@ -351,3 +351,68 @@ func mapBackups(entries []nginx.BackupEntry) []NginxBackupInfo { } return out } + +// NginxConfigVersionInfo 描述主配置(nginx.conf)的一个历史版本。 +type NginxConfigVersionInfo struct { + Name string `json:"name"` + Size int64 `json:"size"` + CreatedAt string `json:"created_at"` +} + +type NginxConfigVersionsView struct { + Message string `json:"message,omitempty"` + Content string `json:"content"` + Versions []NginxConfigVersionInfo `json:"versions"` +} + +type NginxConfigVersionView struct { + Name string `json:"name"` + Content string `json:"content"` +} + +// GetNginxConfigVersions 返回当前主配置与其历史版本,前端拿两者做差异对比。 +func (s *ProxyService) GetNginxConfigVersions() (NginxConfigVersionsView, error) { + content, err := s.nginx.ReadConfig() + if err != nil { + return NginxConfigVersionsView{}, err + } + versions, err := s.nginx.ListConfigVersions() + if err != nil { + return NginxConfigVersionsView{}, err + } + return NginxConfigVersionsView{Content: content, Versions: mapConfigVersions(versions)}, nil +} + +func (s *ProxyService) ReadNginxConfigVersion(name string) (NginxConfigVersionView, error) { + content, err := s.nginx.ReadConfigVersion(name) + if err != nil { + return NginxConfigVersionView{}, err + } + return NginxConfigVersionView{Name: name, Content: content}, nil +} + +// RollbackNginxConfigVersion 回滚主配置;校验不过时 nginx 层不会动磁盘上的当前配置。 +func (s *ProxyService) RollbackNginxConfigVersion(ctx context.Context, name string) (NginxConfigVersionsView, error) { + result, err := s.nginx.RollbackConfigVersion(ctx, name) + if err != nil { + return NginxConfigVersionsView{}, err + } + view, err := s.GetNginxConfigVersions() + if err != nil { + return NginxConfigVersionsView{}, err + } + view.Message = result.Message + return view, nil +} + +func mapConfigVersions(entries []nginx.ConfigVersion) []NginxConfigVersionInfo { + out := make([]NginxConfigVersionInfo, 0, len(entries)) + for _, entry := range entries { + out = append(out, NginxConfigVersionInfo{ + Name: entry.Name, + Size: entry.Size, + CreatedAt: entry.CreatedAt.Format("2006-01-02T15:04:05Z"), + }) + } + return out +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 85f873a..f7e5042 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -8,68 +8,74 @@ import ( ) const ( - KeyTheme = "theme" - KeyTimezone = "timezone" - KeyLogRetentionDays = "log_retention_days" - KeyDDNSCheckInterval = "ddns_check_interval_minutes" - KeyCertRenewThreshold = "cert_renew_threshold_days" - KeyRootDomain = "root_domain" - KeyACMEEmail = "acme_email" - KeyACMECA = "acme_ca" - KeyZeroSSLAPIKey = "zerossl_api_key" - KeyNotifyWebhookURL = "notify_webhook_url" - KeyNotifyType = "notify_type" - KeyNotifyEmailJSON = "notify_email_json" - KeyNotifySMTPPassword = "notify_smtp_password" - KeyNotifyWebhookJSON = "notify_webhook_json" - KeyNotifyWebhookSecret = "notify_webhook_secret" - KeyNotifyTelegramJSON = "notify_telegram_json" - KeyNotifyTelegramToken = "notify_telegram_token" - KeyNotifyOnDDNSError = "notify_on_ddns_error" // legacy, migrated to KeyNotifyOnDDNSFailure - KeyNotifyOnCertError = "notify_on_cert_error" // legacy, migrated to KeyNotifyOnCertExpiry - KeyNotifyOnNginxError = "notify_on_nginx_error" // legacy, ignored - KeyNotifyOnDDNSIPChange = "notify_on_ddns_ip_change" - KeyNotifyOnDDNSFailure = "notify_on_ddns_failure" - KeyNotifyOnCertExpiry = "notify_on_cert_expiry" - KeyNotifyOnCertRenewSuccess = "notify_on_cert_renew_success" - KeyNotifyOnIPFrequentAccess = "notify_on_ip_frequent_access" - KeyNotifyOnCertRenewFailure = "notify_on_cert_renew_failure" - KeyNotifyOnLoginFailure = "notify_on_login_failure" - KeyNotifyOnNginxReloadFailure = "notify_on_nginx_reload_failure" - KeyNotifyIPFrequentThreshold = "notify_ip_frequent_threshold" - KeyNotifyIPFrequentWindowSec = "notify_ip_frequent_window_sec" - KeyNotifyLoginFailureThreshold = "notify_login_failure_threshold" - KeyNotifyLoginFailureWindowSec = "notify_login_failure_window_sec" - KeyTrustedProxy = "trusted_proxy_json" - KeyGlobalIPBlacklist = "global_ip_blacklist" - KeyGlobalIPWhitelist = "global_ip_whitelist" - KeyChinaCIDRUpdateHours = "china_cidr_update_interval_hours" - KeyChinaCIDRSourceV4 = "china_cidr_source_url_v4" - KeyChinaCIDRSourceV6 = "china_cidr_source_url_v6" - KeyChinaCIDRUpdatedAt = "china_cidr_updated_at" - KeyChinaCIDRCountV4 = "china_cidr_count_v4" - KeyChinaCIDRCountV6 = "china_cidr_count_v6" - KeyChinaCIDRLastError = "china_cidr_last_error" - KeyFRPEnabled = "frp_enabled" - KeyFRPServerAddr = "frp_server_addr" - KeyFRPServerPort = "frp_server_port" - KeyFRPAuthToken = "frp_auth_token" - KeyFRPTLSEnabled = "frp_tls_enabled" - KeyFRPCustomDomains = "frp_custom_domains" - KeyFRPLastError = "frp_last_error" - KeyFRPStartedAt = "frp_started_at" - KeyNginxGlobalMode = "nginx_global_mode" + KeyTheme = "theme" + KeyTimezone = "timezone" + KeyLogRetentionDays = "log_retention_days" + KeyDDNSCheckInterval = "ddns_check_interval_minutes" + KeyCertRenewThreshold = "cert_renew_threshold_days" + KeyRootDomain = "root_domain" + KeyACMEEmail = "acme_email" + KeyACMECA = "acme_ca" + KeyZeroSSLAPIKey = "zerossl_api_key" + KeyNotifyWebhookURL = "notify_webhook_url" + KeyNotifyType = "notify_type" + KeyNotifyEmailJSON = "notify_email_json" + KeyNotifySMTPPassword = "notify_smtp_password" + KeyNotifyWebhookJSON = "notify_webhook_json" + KeyNotifyWebhookSecret = "notify_webhook_secret" + KeyNotifyTelegramJSON = "notify_telegram_json" + KeyNotifyTelegramToken = "notify_telegram_token" + KeyNotifyOnDDNSError = "notify_on_ddns_error" // legacy, migrated to KeyNotifyOnDDNSFailure + KeyNotifyOnCertError = "notify_on_cert_error" // legacy, migrated to KeyNotifyOnCertExpiry + KeyNotifyOnNginxError = "notify_on_nginx_error" // legacy, ignored + KeyNotifyOnDDNSIPChange = "notify_on_ddns_ip_change" + KeyNotifyOnDDNSFailure = "notify_on_ddns_failure" + KeyNotifyOnCertExpiry = "notify_on_cert_expiry" + KeyNotifyOnCertRenewSuccess = "notify_on_cert_renew_success" + KeyNotifyOnIPFrequentAccess = "notify_on_ip_frequent_access" + KeyNotifyOnCertRenewFailure = "notify_on_cert_renew_failure" + KeyNotifyOnLoginFailure = "notify_on_login_failure" + KeyNotifyOnNginxReloadFailure = "notify_on_nginx_reload_failure" + KeyNotifyIPFrequentThreshold = "notify_ip_frequent_threshold" + KeyNotifyIPFrequentWindowSec = "notify_ip_frequent_window_sec" + KeyNotifyLoginFailureThreshold = "notify_login_failure_threshold" + KeyNotifyLoginFailureWindowSec = "notify_login_failure_window_sec" + KeyNotifyOnResourceThreshold = "notify_on_resource_threshold" + KeyNotifyOnTunnelDown = "notify_on_tunnel_down" + KeyNotifyCPUThreshold = "notify_cpu_threshold" + KeyNotifyMemoryThreshold = "notify_memory_threshold" + KeyNotifyDiskThreshold = "notify_disk_threshold" + KeyNotifyTunnelFailThreshold = "notify_tunnel_fail_threshold" + KeyTrustedProxy = "trusted_proxy_json" + KeyGlobalIPBlacklist = "global_ip_blacklist" + KeyGlobalIPWhitelist = "global_ip_whitelist" + KeyChinaCIDRUpdateHours = "china_cidr_update_interval_hours" + KeyChinaCIDRSourceV4 = "china_cidr_source_url_v4" + KeyChinaCIDRSourceV6 = "china_cidr_source_url_v6" + KeyChinaCIDRUpdatedAt = "china_cidr_updated_at" + KeyChinaCIDRCountV4 = "china_cidr_count_v4" + KeyChinaCIDRCountV6 = "china_cidr_count_v6" + KeyChinaCIDRLastError = "china_cidr_last_error" + KeyFRPEnabled = "frp_enabled" + KeyFRPServerAddr = "frp_server_addr" + KeyFRPServerPort = "frp_server_port" + KeyFRPAuthToken = "frp_auth_token" + KeyFRPTLSEnabled = "frp_tls_enabled" + KeyFRPCustomDomains = "frp_custom_domains" + KeyFRPLastError = "frp_last_error" + KeyFRPStartedAt = "frp_started_at" + KeyNginxGlobalMode = "nginx_global_mode" ) var Defaults = map[string]string{ - KeyTheme: "system", - KeyTimezone: "Asia/Shanghai", - KeyLogRetentionDays: "30", - KeyDDNSCheckInterval: "5", - KeyCertRenewThreshold: "30", - KeyACMECA: "letsencrypt", - KeyChinaCIDRUpdateHours: "24", - KeyNginxGlobalMode: "auto", + KeyTheme: "system", + KeyTimezone: "Asia/Shanghai", + KeyLogRetentionDays: "30", + KeyDDNSCheckInterval: "5", + KeyCertRenewThreshold: "30", + KeyACMECA: "letsencrypt", + KeyChinaCIDRUpdateHours: "24", + KeyNginxGlobalMode: "auto", } type Store struct { diff --git a/migrations/025_api_tokens.sql b/migrations/025_api_tokens.sql new file mode 100644 index 0000000..7ebc87f --- /dev/null +++ b/migrations/025_api_tokens.sql @@ -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')) +); diff --git a/web/package.json b/web/package.json index 849abd1..3888b72 100644 --- a/web/package.json +++ b/web/package.json @@ -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": { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 60bc475..e7acf18 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -223,7 +223,24 @@ export const api = { method: 'POST', body: JSON.stringify(backup ? { backup } : {}), }), + getNginxConfigVersions: () => + request('/api/settings/nginx/config/versions'), + getNginxConfigVersion: (name: string) => + request(`/api/settings/nginx/config/versions/${encodeURIComponent(name)}`), + rollbackNginxConfigVersion: (name: string) => + request( + `/api/settings/nginx/config/versions/${encodeURIComponent(name)}/rollback`, + { method: 'POST' }, + ), getChinaCIDRStatus: () => request('/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('/api/settings/tokens', { + method: 'POST', + body: JSON.stringify(payload), + }), + deleteApiToken: (id: number) => + request<{ ok: boolean }>(`/api/settings/tokens/${id}`, { method: 'DELETE' }), refreshChinaCIDR: () => request('/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(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}/versions`), + getFrpAgentRouteVersion: (serverId: number, proxyId: number, name: string) => + request( + `/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) => diff --git a/web/src/api/types.ts b/web/src/api/types.ts index deb077e..8294c35 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -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 +} diff --git a/web/src/components/ConfigDiffView.vue b/web/src/components/ConfigDiffView.vue new file mode 100644 index 0000000..742051b --- /dev/null +++ b/web/src/components/ConfigDiffView.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/web/src/constants/notifyEvents.ts b/web/src/constants/notifyEvents.ts index 710ee27..4eb1804 100644 --- a/web/src/constants/notifyEvents.ts +++ b/web/src/constants/notifyEvents.ts @@ -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 diff --git a/web/src/constants/notifyProviders.ts b/web/src/constants/notifyProviders.ts index 1b07dd3..3cee6a6 100644 --- a/web/src/constants/notifyProviders.ts +++ b/web/src/constants/notifyProviders.ts @@ -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 = '********' diff --git a/web/src/styles/variables.css b/web/src/styles/variables.css index f8f7fad..f413386 100644 --- a/web/src/styles/variables.css +++ b/web/src/styles/variables.css @@ -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; diff --git a/web/src/utils/diff.ts b/web/src/utils/diff.ts new file mode 100644 index 0000000..62d651f --- /dev/null +++ b/web/src/utils/diff.ts @@ -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(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') +} diff --git a/web/src/views/ProxyRemoteView.vue b/web/src/views/ProxyRemoteView.vue index 28f45f3..ebebc0c 100644 --- a/web/src/views/ProxyRemoteView.vue +++ b/web/src/views/ProxyRemoteView.vue @@ -340,7 +340,7 @@ - +
@@ -353,7 +353,49 @@ fonu-{{ confDomain }}.conf)。请勿在 VPS 上手工修改: 下次部署会被重新生成覆盖,改动请到「部署设置」或穿透规则里调整。

-
{{ confContent || '加载中…' }}
+
+ +
+ +
{{ confContent || '加载中…' }}
+
+ 回滚前会先做语法校验与 nginx -t,校验不过不会覆盖当前配置。 + + + 确定把 {{ confDomain }} 的 vhost 回滚到该版本? + +
+
+
@@ -421,6 +485,50 @@ class="nginx-framework__editor" /> +
+ 主配置版本与回滚({{ configVersions.length }} 个历史版本) +

+ 保存规则或设置时会重新生成 nginx.conf,这里保留最近 10 次内容有变化的版本。 + 若配置被外部改动导致 Nginx 起不来,可直接回滚(回滚前先校验语法与 nginx -t,不过关不会覆盖当前配置)。 +

+

{{ configVersionsError }}

+
+
+
+ {{ formatDate(version.created_at) }} + {{ formatBytes(version.size) }} +
+

暂无历史版本

+
+
+ + +
+ 回滚会先校验该版本,通过后才写回并重载 Nginx。 + + + 确定把主配置回滚到该版本? + +
+
+
+
小白勿碰。若保存后 Nginx 启动失败,请点「恢复自动生成」或「回滚」还原配置。 + + +

+ 浏览器之外的调用方拿不到会话 cookie,可在这里签发令牌,用 + Authorization: Bearer <token> 调用接口。明文只在创建时显示一次。 +

+ +
+ 令牌已创建,请立即复制 + 关闭后无法再次查看明文,只能撤销重建。 +
+
+ {{ createdToken }} + 复制 +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + + 创建令牌 + +
+
+ + +
+
+
+ {{ token.name }} + +
+
+ 创建 {{ formatDate(token.created_at) }} + {{ token.last_used_at ? `最近使用 ${formatDate(token.last_used_at)}` : '尚未使用' }} + {{ token.expires_at ? `有效期至 ${formatDate(token.expires_at)}` : '永不过期' }} +
+ + + 撤销后该令牌立即失效,确定继续? + +
+
+

还没有令牌。

@@ -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>({ 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([]) +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([]) +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) { 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) { 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) { 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() +})