Skip to content

feat: 掉线、流量、到期与登录通知(Telegram / Webhook) - #10

Merged
stqfdyr merged 4 commits into
mainfrom
feat/notify
Sep 16, 2026
Merged

stqfdyr merged 4 commits into
mainfrom
feat/notify

Conversation

@stqfdyr

@stqfdyr stqfdyr commented Sep 15, 2026 •

Copy link
Copy Markdown
Collaborator

概要

新增通知功能,全部在 hub 侧完成,agent 与主题不需要升级,无新依赖。后台新增「通知」页。

事件

事件 触发 开关
🔴 离线 / 🟢 恢复 hub 发现断开后超过宽限期(默认 3 分钟);只有报过离线的节点才发恢复 按节点,默认关,通知页可批量打开
⚠️ 流量提醒 本期用量达到阈值(默认 80%)和 100% 各一次 节点填了流量额度即生效
⏳ 到期 / 🔁 续期 每天 9 点汇总 N 天内(默认 7)到期的节点;自动顺延到期日时 节点填了到期日即生效
🔑 登录 应急密码或 GitHub 登录成功(不报失败) 全局开关,默认开

渠道与内容

  • Telegram(纯文本)与 Webhook(POST JSON)可同时启用
  • 每个渠道一个模板,占位符 {{title}} {{message}} {{node}} {{event}} {{site}} {{time}},后台实时预览;Webhook 请求体保存时校验代入后是合法 JSON
  • bot token、Webhook URL、请求头只写不回读

防通知风暴

  • 每 30 秒巡检一次,同一轮的离线、恢复、流量各合并为一条
  • 1 小时内有过超过宽限期掉线的节点,下一次掉线满 30 分钟才报;稳定在线 1 小时后恢复正常
  • 一条消息最多列 20 台节点(Discord 2000 字符、企业微信 2048 字节上限)

模拟一小时(真实巡检函数 + 虚拟时钟):

场景 改前 改后
在线 1 分钟 / 掉线 4 分钟,宽限期 3 分钟 23 条 2 条
在线 35 秒 / 掉线 95 秒,宽限期 1 分钟 54 条 2 条
30 台节点已过 80% 时打开流量提醒 30 条 1 条
hub 断网,40 台节点 2 条 2 条

可靠性与安全

  • 离线从 hub 发现断开时计时,与 agent 的 --interval 无关(上报间隔 300 秒的 agent 重启几秒不会误报)
  • 已报离线的状态落库(node.down_since),hub 重启不重发、恢复照常补发;未配置任何渠道时不标记
  • 单队列串行投递,只对连接错误、5xx、429 重试;401 这类错误不重试,不拖慢另一个渠道
  • 通知用独立的 HTTP client 且不跟随跳转:否则 POST 遇 301 会变成不带内容的 GET 并被当作发送成功,自定义凭证头也会被带到跳转后的主机
  • 发送错误去掉 URL 后才进日志和面板(Telegram 的 URL 含 bot token)
  • 每条通知最多投递一次;hub 自身停机时无法发出通知

数据库

schema 升到 4:node 表新增 notify、down_since,旧库启动时自动迁移。

顺带

install-hub.sh 的单元补上 RestrictSUIDSGID=yes,与 agent 单元一致(#8 在换掉 DynamicUser= 时明写了这一项,DynamicUser= 原本隐含它;hub 从未有过)。与通知无关,一行,搭这趟车。

测试

  • cargo test(97 个)、cargo clippy --all-targets 零警告、cargo fmt --check
  • web-admin:npm run lint、npm test、npm run build
  • 端到端(本地 hub + 真 agent + 本地 Webhook 接收端):测试发送、登录提醒、离线/恢复、hub 重启不重发且补发恢复、--interval 300 的 agent 断开 79 秒后才报(宽限期 1 分钟)
  • 跳转地址报 301 且接收端不再收到跳转后的 GET;自定义 Content-Type 只发一个;Telegram 假 token 1.8 秒放弃、日志与面板不含 token
  • systemd-analyze security --offline=true 比对 hub 单元前后,差异只有 RestrictSUIDSGID=
  • 离线通知「全部打开」:8 台节点、模拟 100 ms 往返,8 个 PUT 并发发出(53–58 ms),最后一个响应 247 ms;串行版同条件下依次发出、跨度 854 ms
  • 浏览器验收:通知页、节点弹窗开关、批量开关、单独清除请求头、模板预览与非法 JSON 提示、390px 宽度无横向溢出

- 事件:节点离线/恢复(按节点开关,默认关,后台可批量打开)、本期流量
  达到阈值与 100%、每天 9 点的到期汇总与自动续期、后台登录成功
- 渠道:Telegram 与 Webhook 可同时启用。每个渠道一个模板,占位符
  {{title}} {{message}} {{node}} {{event}} {{site}} {{time}},后台实时预览;
  Webhook 请求体保存时校验代入后是合法 JSON
- 防风暴:每轮巡检合并为一条;1 小时内有过超过宽限期掉线的节点,下一次
  掉线满 30 分钟才报;一条最多列 20 台节点
- 离线从 hub 发现断开时计时,与 agent 上报间隔无关;已报离线的状态落库,
  hub 重启不重发,恢复照常补发;未配置渠道时不标记
- 投递:单队列串行,仅对连接错误、5xx、429 重试;不跟随跳转,避免 POST
  被改成 GET 以及凭证头被带到其它主机;错误信息去掉 URL
- bot token、Webhook URL 与请求头只写不回读
- schema 升到 4:node 表新增 notify、down_since
标题行显示「已配置 / 未配置」,点击或回车展开。用原生 details,与节点
弹窗的「流量校正」一致。
agent 单元在换掉 DynamicUser= 时明写了这一项,hub 单元一直没有,两边的
提权约束因此不一致。hub 同样不创建 setuid/setgid 文件,加上它没有代价。
apply() 原先是 for await 串行,N 台节点就是 N 个首尾相接的往返;节点列表
又由 WebSocket 每 2 秒推一次全量,中间状态因此被逐帧渲染成一台一台打开。

改为 Promise.all。8 台节点、模拟 100 ms 往返下实测:8 个 PUT 的发出时刻由
40/121/234/351/465/667/679/789 ms 收敛到 53–58 ms,最后一个响应由 894 ms
提前到 247 ms。

未加批量端点:PUT /nodes/{id} 是节点弹窗每个字段共用的写入路径,再加一条
只写 notify 的路由会让同一列有两个写入口。节点上百时再议。
@stqfdyr
stqfdyr merged commit 3ddc27a into main Sep 16, 2026
1 check passed
@stqfdyr
stqfdyr deleted the feat/notify branch September 16, 2026 03:52
kofwj added a commit to kofwj/monitor that referenced this pull request Sep 16, 2026
一个节点一个开关,关掉的完全不参与告警判断。上游 notify.rs 有这个能力而本地
alerts.rs 没有,代价是临时节点和还在调试的机器只能靠把整条规则关掉来消音 ——
那是把所有节点一起关掉。

存成 setting 表的 alert_muted_nodes(逗号分隔的节点 id),**不动 schema**,
SCHEMA_VERSION 保持 4。加列会撞上上游 PR monitor-probe#10 的 node.notify(他们也是往 node
上加列),而按节点静音用一张 id 列表就够。存 id 不存名字:节点可以改名,跟着
名字走的静音会转移到接管了这个名字的节点上。

静音的节点不是「判为安静」,而是根本不判。这两件事的差别在解除静音的那一刻:
判为安静会写下 alert_state 那一行,而正是这一行让下一轮不播报 —— 于是静音
期间真的停机了,解除后也不会说。所以静音节点每轮把它的行删掉,解除后立刻
报告当前成立的情况。这也是规则被关掉时已有的做法(`forget_alerts`),只是
换成一个节点的粒度。

开关写在页面上即时生效,不放在「保存」按钮后面:一个要等别的按钮才动作的
开关,是一个会被留在错误位置的开关。

验证:cargo fmt --check / clippy --all-targets -D warnings / test 全过
(131 passed,新增 3 条:mute 列表的接受与拒绝、静音节点不记录且解除后立刻
播报、静音一个不影响其他节点);oxlint 0 warning;npm run build 通过。
另起真实进程走了一遍 面板 → API → 数据库:PUT 1,2 后 GET 读回 1,2,
"web" / "1,,2" / "0" 均被 400 拒绝(中文提示),清空读回空串。
kofwj added a commit to kofwj/monitor that referenced this pull request Sep 16, 2026
除 Telegram 之外再推一份到任意 HTTP 地址。设置键两个:`alert_webhook_url` 与
`alert_webhook_headers`(逐行 `Name: value`,覆盖 Gotify / n8n / Home Assistant
这类要 token 的目标)。请求体固定为 JSON,不做模板:`hub` 是站点名,`text` 是
整条消息,`lines` 是拆开的分组。模板是上游 PR monitor-probe#10 的做法,但存进去的 JSON 写错
只在发送时才炸;这里宁可少一个自由度,换「对面不必按换行猜哪半截是节点名」。

为此把渲染与投递拆开:`render` 出结构化的 `(heading, line)`,`compose` 按渠道
出 Telegram 的 HTML 或纯文本。顺带修掉 `render` 里一处算错的权重:分片之后新
消息会重新写一次标题,原来的推算没算进去,于是消息可能超过 `BUDGET` —— 而
Telegram 会拒收,hub 就会一直重试同一条。

投递语义变了,这是有意的:**任一渠道收下即视为已投递**,只有所有渠道都没收下
才留着下一轮重试。若按「全部成功才算」,一个配错的 webhook 会让已经发到 Telegram
的告警每 30 秒重发一次,把一次停机变成刷屏 —— 比第二个渠道漏收更糟。没收下的
渠道连名带错写进日志,那才是修它的地方。

同时修正 `db.rs` 中 `migrate_to_4` 的注释:它仍把 PR monitor-probe#10 写成 unmerged,而该 PR
已于 2026-09-16 合入上游。schema 版本号仍保持 4 不动 —— 本 fork 尚未合并上游,
两个 `migrate_to_4` 还没见面。

验证:138 个测试全绿(新增 6 个);另跑真实进程的端到端 —— 起 hub、登录、写设置、
点测试按钮,确认 mock 收到的请求头与 JSON 载荷;地址缺 scheme、请求头缺冒号都被
400 拒掉;地址指向死端口时 502 点名 Webhook 且错误里不带 URL(webhook 的凭据常在
query 上);日志里没有凭据。
CarlJia referenced this pull request in CarlJia/monitor Sep 16, 2026
独立评审(7 路本地 + codex 对抗尝试)后的修复。P0/P1 全部经独立校验器确认。

阻断项:
- #1/#4 wasm 在运行时上下文里被同步调用,宿主函数里的嵌套 block_on 会
  panic;release 档 panic=abort 直接终止进程,启用财务插件即崩。
  call_hook/call_json_hook 补 block_in_place(与 run_one 同一形态),
  call_plugin_json 去掉多余的外层 block_on。新增 run_blocking:只在多线程
  运行时上包一层,纯同步/单线程路径直接跑。
- #2 SSRF 预检只覆盖首个 URL,而共享 client 默认跟随重定向(含
  https->http 降级),任一公网开放重定向即可绕过。新增 App::plugin_http
  (Policy::none()),插件出网与宿主隔离。
- #7 注册表读锁横跨插件执行,插件回头发事件重入同一把锁会死锁。
  dispatch_ticks/call_json/dispatch_one 改为只在取快照时借锁。

迁移与数据边界:
- #5 删列闸门按 plugin_id 限定(不再被第三方插件的 node: 前缀打开)。
- #15 四条 DROP COLUMN 收进一个事务,完成判据与待删清单同源。
- #16 Db::open 补 version > SCHEMA_VERSION 守卫(与 check_backup 一致)。
- #17 GATED_VERSION 用绝对常量,不再随 SCHEMA_VERSION 漂移。
- #6 删列前记一行非默认值行数,运维在日志里看得到这次丢了什么。
- #18/#23 plugin_data 配额统一按字节,检查与写入同一事务(BEGIN IMMEDIATE)。
- #20 无到期字段的插件事件按 payload 内容哈希(FNV-1a)定键,不再永久去重。
- #21/#22 SSRF 预检的 DNS 解析与请求超时都按剩余预算收缩。
- #24 data_list/nodes_query 用各自的上限,放不下报 -6 而不是静默截断。

契约与文档:
- #13/#14 插件端点先判加载状态(400)再判能力声明(404);错误体统一纯文本。
- #8/#9/#29 README 的 nodes_query 字段、data_list 格式、tick 周期按实现改写。
- #26 表单控件按字段名推断的规则写进协议(#27 同步修掉清空价格存成 0)。
- #25 破坏性 ABI/API 变更按 SemVer 升到 2.0.0,README 补升级说明。
- #28 tg-notify 错误码表补 -9 对应的 19。

测试与 CI:
- #10 删除空测试体;#11 nodes_query 测试播种节点并断言在线状态。
- #19 插件 crate 接进 CI(独立 crate,根 cargo test 覆盖不到)。
- #12 插件区分「宿主查询失败」与「确实没有节点」,清理拿不到存活集合拒删。
- #3 抽出 plugin_http_fetch,http_post/http_get 不再逐行重复。

验证:hub 166 passed;finance-stats 8;tg-notify 3;web-admin build+lint。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
liz963 added a commit to liz963/monitor that referenced this pull request Sep 16, 2026
通知实现整体改用 monitor-k 的版本:PushPlus 与 SMTP 为新增渠道,连同上游的
Telegram、Webhook 共四个,每个渠道一个开关、全部挂在总开关之下,只在该渠道最
小配置齐备时才调用;单渠道失败只记一条 warn,不影响其他渠道和主进程。上游那套
「队列 + 重试 + 模板 + 防风暴 + 每日摘要」的通知器(notes 通道、
deliver/watch/expiry_digest/renewed 与 /api/notify/test)整体移除,节点上下线
改由 spawn_node_watcher 按 120s 心跳窗口判定。

随之不再有「按节点开关」的通知语义:node.notify 与 node.down_since 两列保留在
schema 里、migrate_to_4 一字未动,好让已经 stamped 4 的库不必在新含义下重跑一
次;但 Rust 侧不再读写它们,Node/NodePatch 的字段与 set_down_since 一并删除。

顺带并入上游 monitor-probe#10 对 install-hub.sh 的加固:RestrictSUIDSGID=yes。

注:本提交里 auth.rs 暂时摘掉了登录通知的调用点,由随后的「密码账号与登录审计」
提交以 login_notice 的形式重新接回。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant