feat: P0 通知(邮件 + MeoW)与 TLS 证书监控,事件时间线与维护窗口

通知(P0-1):
- 邮件:自写 SMTP 客户端,零依赖。465 隐式 TLS / 587·25 STARTTLS、
  AUTH LOGIN+PLAIN、RFC2047 中文主题、base64 正文、多收件人;
  服务器不支持 STARTTLS 时默认拒绝明文(需显式 allowInsecure)
- MeoW:按官方文档实现,昵称寻址、msgType/htmlHeight 走查询参数、
  以响应体 status 判定成功(HTTP 恒 200)、429/404/403 可读化、
  本地限流器遵守 3s/15min/60h
- 告警:连续 N 次失败去抖 + 恢复通知 + 同轮合并 + 去抖状态持久化
  (data/notify-state.json,gitignore)+ 静默时段(跨零点)
- --test-notify 自检命令、--data-dir / SHIROKO_DATA_DIR

证书(P0-2):
- 独立 TLS 握手取 notAfter(不动既有 fetch 探测路径),默认 6 小时一次
- 面板徽标 ≤14 天黄 / ≤7 天红,阈值 14/7/3 各告一次、已过期只报最严重一档
- 单个目标可用 tls: false 关闭

事件与维护(P1-1 的后端部分):
- data/events.json 追加式事件流(最近 500 条)+ GET /api/events +
  快照内附最近 20 条;redactDetails 时脱敏
- 维护窗口:窗口内不计入可用率、不发告警、显示「维护中」徽标

测试抓到并修复的三个真 bug:
1. SMTP 空闲超时无人处理 → 服务器不响应会把探针循环挂死
2. MeoW 限流器把空历史当 0 → 第一条通知永远被自己拦掉
3. 去抖状态在无事轮次不落盘 → 连续失败计数丢失、永远达不到阈值

验证:新增 verify-notify.cjs 39 项(假 SMTP / 假 MeoW / 三轮集成 / 维护窗口 /
真实证书握手),六套合计 154 项全通过。
This commit is contained in:
tamakyi
2026-10-13 23:54:46 +08:00
parent ea846dee31
commit bdaa31f10c
17 changed files with 1545 additions and 291 deletions
+2
View File
@@ -9,3 +9,5 @@ local.monitors.json
backups/ backups/
# 助手的工作记录(含本机信息与运维笔记,默认不入库;如需版本化可删除此行) # 助手的工作记录(含本机信息与运维笔记,默认不入库;如需版本化可删除此行)
.workbuddy/ .workbuddy/
# 运行期私有数据:通知去抖状态与事件时间线
data/
+100 -2
View File
@@ -45,6 +45,12 @@ node prober/node/index.js --once --demo
# 自定义快照输出路径(--demo 会覆盖真实快照,需要保留真实数据时用它写到别处) # 自定义快照输出路径(--demo 会覆盖真实快照,需要保留真实数据时用它写到别处)
node prober/node/index.js --once --demo --out=dist/status.json node prober/node/index.js --once --demo --out=dist/status.json
# 通知渠道自检:给每个启用的渠道发一条测试消息(配好 SMTP / MeoW 后先跑这个)
node prober/node/index.js --test-notify
# 通知状态与事件时间线的存放目录(默认 ./data,也可用环境变量 SHIROKO_DATA_DIR)
node prober/node/index.js --data-dir=/var/lib/shiroko-monitor
``` ```
启动后打开 <http://localhost:8787/> 即可看到面板。无需安装任何依赖。 启动后打开 <http://localhost:8787/> 即可看到面板。无需安装任何依赖。
@@ -71,7 +77,7 @@ shiroko-monitor/
│ ├── app.js # 数据源解析 / 渲染 / 浏览器直连探测 / 主题切换 │ ├── app.js # 数据源解析 / 渲染 / 浏览器直连探测 / 主题切换
│ └── favicon.svg # 默认主题图标 │ └── favicon.svg # 默认主题图标
├── prober/node/ # Node 探针(本地 / CI 用) ├── prober/node/ # Node 探针(本地 / CI 用)
│ ├── index.js # 巡检循环 + 静态服务器 + /api/* + /api/v2/getMonitors + --once/--demo/--out/--config │ ├── index.js # 巡检循环 + 静态服务器 + /api/* + /api/v2/getMonitors + --once/--demo/--out/--config/--test-notify
│ └── lib/ │ └── lib/
│ ├── http-probe.js # HTTP 探测(fetch,redirect: manual) │ ├── http-probe.js # HTTP 探测(fetch,redirect: manual)
│ ├── tcp-probe.js # TCP 探测(net.connect) │ ├── tcp-probe.js # TCP 探测(net.connect)
@@ -119,6 +125,14 @@ shiroko-monitor/
"refreshCooldownSec": 5, // 每个来源的触发冷却(秒) "refreshCooldownSec": 5, // 每个来源的触发冷却(秒)
"hideTargets": false, // true = 面板不渲染任何地址(对外发布用) "hideTargets": false, // true = 面板不渲染任何地址(对外发布用)
"redactDetails": false, // true = 快照 detail 只保留「正常/异常」 "redactDetails": false, // true = 快照 detail 只保留「正常/异常」
"notify": { // 通知:故障/恢复/证书到期(SMTP 密码与 MeoW 昵称请放 local.monitors.json)
"enabled": false,
"failureThreshold": 2,
"quietHours": "",
"channels": { "email": { "enabled": false }, "meow": { "enabled": false } }
},
"tls": { "enabled": true, "checkIntervalSec": 21600, "alertDays": [14, 7, 3] },
"maintenance": [], // 维护窗口:[{ from, to, id?, note? }]
"uptimeRobot": { // UptimeRobot 只读数据源(Key 建议放 local.monitors.json) "uptimeRobot": { // UptimeRobot 只读数据源(Key 建议放 local.monitors.json)
"enabled": false, "enabled": false,
"apiKey": "", "apiKey": "",
@@ -352,6 +366,76 @@ UptimeRobot 的探测来自其多个节点,适合作为**异地视角**与自
- **浏览器直连模式**:不记录历史,色条显示灰色、「可用率数据积累中」——需要可用率请使用 Node 探针或边缘 Worker - **浏览器直连模式**:不记录历史,色条显示灰色、「可用率数据积累中」——需要可用率请使用 Node 探针或边缘 Worker
- **`--demo`** 会生成 90 天演示分桶(含绿/橙/红各档),仅用于界面预览;若不想覆盖真实快照,用 `--out=<path>` 写到别处 - **`--demo`** 会生成 90 天演示分桶(含绿/橙/红各档),仅用于界面预览;若不想覆盖真实快照,用 `--out=<path>` 写到别处
## 通知、证书与事件
### 通知(邮件 + MeoW)
面板只在你打开时展示状态,**通知**让故障主动找你。支持两个渠道:**邮件**(自写 SMTP 客户端,零依赖)与 **MeoW 推送**(按 [官方文档](https://www.chuckfang.com/MeoW/api_doc.html) 实现)。
```jsonc
"notify": {
"enabled": true,
"failureThreshold": 2, // 连续失败 N 次才告警(去抖,默认 2;1 = 立即)
"recoveryNotify": true, // 恢复时也发一条
"quietHours": "23:00-07:00", // 静默时段(本地时间,支持跨零点),期间只记录不发送;留空全天
"channels": {
"email": {
"enabled": true,
"host": "smtp.example.com", "port": 465, // 465 隐式 TLS;587/25 自动 STARTTLS
"user": "[email protected]", "pass": "密码",
"from": "[email protected]", "to": ["[email protected]"],
"allowInsecure": false // 服务器不支持 STARTTLS 时是否允许明文(默认拒绝)
},
"meow": {
"enabled": true,
"nickname": "你的昵称", // MeoW 以昵称寻址、无需 token,因此昵称等同密钥
"baseUrl": "https://api.chuckfang.com",
"msgType": "text" // text | html(html 可另配 htmlHeight)
}
}
}
```
行为要点:
- **去抖**:连续失败达到阈值才发「故障」;恢复时发「恢复」;同一轮多个目标变化会合并成一条,不刷屏
- **重启不重复**:去抖状态持久化在 `data/notify-state.json`(私有目录,已 gitignore),重启/单轮模式都不重发
- **静默时段**:支持 `23:00-07:00` 这类跨零点写法;静默期内照常记录事件,只是不发送
- **MeoW 限流**:客户端本地也做限流(3 秒 1 条 / 15 每分钟 / 60 每小时),替你挡掉一部分官方 429;429/404/403 都会转成可读的错误原因
- **CI 注意**:MeoW 官方明确不建议用多人共享的出口 IP(GitHub Actions 即属此类),CI 场景建议只开邮件渠道
- **自检**:`node prober/node/index.js --test-notify` 给每个启用渠道发一条测试消息,确认配置可用
> SMTP 密码与 MeoW 昵称属于私密信息,请放在 `local.monitors.json`(已 gitignore),不要写进公开的 `monitors.json`。
### TLS 证书监控
对 `https://` 目标,探针额外做一次 TLS 握手读取证书有效期(默认 6 小时一次):
- 面板在名称旁显示徽标:剩余 **≤14 天**黄色、**≤7 天**红色,悬停可见到期日与签发者
- 剩余 ≤14 / 7 / 3 天时按阈值告警;同一张证书每个阈值只发一次,换证书后重新计算;**已过期的证书只报最严重的一档**
- 单个目标不需要可设 `"tls": false`
```jsonc
"tls": { "enabled": true, "checkIntervalSec": 21600, "alertDays": [14, 7, 3] }
```
### 维护窗口
计划重启/迁移期间,不想污染可用率统计、也不想被告警轰炸:
```jsonc
"maintenance": [
{ "from": "2026-10-14T02:00", "to": "2026-10-14T04:00", "id": "box", "note": "升级迁移" }
]
```
- `id` 可省略(对所有目标生效),也支持写成数组
- 窗口内:探测照常进行,但**不计入当日可用率**、**不发告警**,面板显示「维护中」徽标;事件时间线照常记录并带 `maintenance: true`
### 事件时间线
每次状态翻转(故障 / 恢复 / 证书阈值)都会追加到 `data/events.json`(私有目录,保留最近 500 条),并通过 `GET /api/events` 读取。快照里也会带上最近 20 条供面板使用;`redactDetails` 开启时,快照内事件只保留类型与时间。
## 部署方案 ## 部署方案
| 方案 | 架构 | 数据更新 | 成本 | 适合场景 | | 方案 | 架构 | 数据更新 | 成本 | 适合场景 |
@@ -445,6 +529,19 @@ UptimeRobot 的探测来自其多个节点,适合作为**异地视角**与自
- [x] 页脚改为展示「数据更新于 … + 下次检查 Ns」,tooltip 内含服务端更新节奏与快照生成时间 - [x] 页脚改为展示「数据更新于 … + 下次检查 Ns」,tooltip 内含服务端更新节奏与快照生成时间
- [x] `scripts/verify.cjs` 适配纯外部数据源部署(自动跳过不适用的探测类断言),并新增「倒计时对齐服务端」断言 - [x] `scripts/verify.cjs` 适配纯外部数据源部署(自动跳过不适用的探测类断言),并新增「倒计时对齐服务端」断言
### v0.10.0 已实现(P0:通知 + 证书)
- [x] **通知渠道①:邮件** —— 自写极简 SMTP 客户端(**零依赖**,不引 nodemailer):465 隐式 TLS / 587·25 自动 STARTTLS、AUTH LOGIN 与 PLAIN、UTF-8 主题按 RFC2047 编码 + base64 正文、多收件人、5xx 与连接错误可读;服务器不支持 STARTTLS 时**默认拒绝明文发信**(需显式 `allowInsecure`)
- [x] **通知渠道②:MeoW 推送** —— 按[官方文档](https://www.chuckfang.com/MeoW/api_doc.html)实现:昵称寻址无 token、`msgType`/`htmlHeight` 走查询参数、**HTTP 恒 200 改为按响应体 status 判定**、429/404/403 转成可读原因、本地限流器遵守 3s/15min/60h
- [x] **告警逻辑**:连续 N 次失败才告警(去抖)+ 恢复通知 + 同轮多点变化合并为一条 + **去抖状态持久化**(重启不重复)+ 静默时段(跨零点,只记录不发送)
- [x] **事件时间线**:`data/events.json`(保留最近 500 条)+ `GET /api/events` + 快照内附最近 20 条;`redactDetails` 时脱敏
- [x] **TLS 证书监控**:对 https 目标单独握手取有效期(默认 6 小时一次),面板徽标(≤14 天黄 / ≤7 天红),阈值 14/7/3 各告一次、已过期只报最严重一档、换证书自动重置
- [x] **维护窗口**:窗口内不计入可用率、不发告警、显示「维护中」徽标,事件仍记录
- [x] **`--test-notify` 自检命令** 与 `--data-dir` 私有数据目录(默认 `./data`,已 gitignore)
- [x] 新增 `scripts/verify-notify.cjs`:**39 项断言**(假 SMTP 服务器 / 假 MeoW 服务器 / 静默时段 / 限流器 / 三轮集成验证去抖与恢复 / 维护窗口抑制 / 真实证书握手)
> 测试过程中抓到并修掉两个真 bug:① SMTP 空闲超时没有处理,服务器不响应会把探针循环**挂死**;② MeoW 限流器把空历史当成 0,**第一条通知永远被自己拦掉**;③ 去抖状态在「无事发生」的轮次不落盘,导致连续失败计数丢失、永远达不到告警阈值。
### v0.9.1 已实现 ### v0.9.1 已实现
- [x] **「任何情况下都不需要横向滚动」定为硬要求并落地**:表格改为**流式列宽**(`table-layout: fixed` + 百分比列宽),表头允许换行、单元格裁剪,心率图与可用率条随宽度伸缩;从结构上不可能撑出横向滚动 - [x] **「任何情况下都不需要横向滚动」定为硬要求并落地**:表格改为**流式列宽**(`table-layout: fixed` + 百分比列宽),表头允许换行、单元格裁剪,心率图与可用率条随宽度伸缩;从结构上不可能撑出横向滚动
@@ -530,10 +627,11 @@ node prober/node/index.js --once
NODE_PATH=<playwright 的 node_modules> node scripts/verify.cjs # 热重载 / 数据一致性 / 刷新语义 / 面板(21 项) NODE_PATH=<playwright 的 node_modules> node scripts/verify.cjs # 热重载 / 数据一致性 / 刷新语义 / 面板(21 项)
NODE_PATH=<playwright 的 node_modules> node scripts/verify-views.cjs # 视图与数据源过滤(10 项) NODE_PATH=<playwright 的 node_modules> node scripts/verify-views.cjs # 视图与数据源过滤(10 项)
node scripts/verify-security.cjs # banner 探测 / 脱敏 / 私有分层 / 刷新限流(20 项) node scripts/verify-security.cjs # banner 探测 / 脱敏 / 私有分层 / 刷新限流(20 项)
node scripts/verify-notify.cjs # 通知与证书(39 项:假 SMTP / 假 MeoW / 去抖 / 恢复 / 维护窗口 / 真实证书)
UPTIMEROBOT_API_KEY=ur... node scripts/verify-uptimerobot.cjs # UR 映射/兼容接口/失败路径/冒烟(22 项) UPTIMEROBOT_API_KEY=ur... node scripts/verify-uptimerobot.cjs # UR 映射/兼容接口/失败路径/冒烟(22 项)
``` ```
`verify-security.cjs` 会临时起一个假 SSH 服务与静默端口,验证 banner 探测的在线/不匹配/超时三种判定、默认不回显版本、脱敏与私有配置分层,以及刷新接口的 429/403 行为。`verify-uptimerobot.cjs` 用合成 payload 做无网映射单测,真实 Key 冒烟为可选(未设置环境变量则跳过)。 `verify-security.cjs` 会临时起一个假 SSH 服务与静默端口,验证 banner 探测的在线/不匹配/超时三种判定、默认不回显版本、脱敏与私有配置分层,以及刷新接口的 429/403 行为。`verify-uptimerobot.cjs` 用合成 payload 做无网映射单测,真实 Key 冒烟为可选(未设置环境变量则跳过)。`verify-notify.cjs` 会临时起**假 SMTP 服务器与假 MeoW 服务器**,完整走一遍邮件握手与推送协议,并真跑三轮探针验证去抖、恢复、维护窗口抑制与事件落盘;真实证书握手需要外网,无网络时自动跳过(不计失败)。
代码约定:探针与面板通过 `status.json` / `/api/status` 的同一份 JSON 契约协作;新增探测类型时只需在 `prober/node/lib/` 与 `edge/cloudflare/worker.js` 各实现一个 `probeXxx(monitor)`,返回 `{ status, latency, code, detail }` 即可被面板直接渲染。 代码约定:探针与面板通过 `status.json` / `/api/status` 的同一份 JSON 契约协作;新增探测类型时只需在 `prober/node/lib/` 与 `edge/cloudflare/worker.js` 各实现一个 `probeXxx(monitor)`,返回 `{ status, latency, code, detail }` 即可被面板直接渲染。
+2 -1
View File
@@ -105,7 +105,8 @@ banner 探测的副作用仅有一次 TCP 连接与读取,与端口扫描器
| 泄露面 | 风险 | 措施 | | 泄露面 | 风险 | 措施 |
| --- | --- | --- | | --- | --- | --- |
| `public/monitors.json` | 面板必需文件,静态部署必然公开,含真实 URL / 内网主机端口 | **私有配置分层**:公开文件只写 `id/name/group`,真实地址放仓库根的 `local.monitors.json`(已 gitignore),探针按 `id` 合并覆盖 | | `public/monitors.json` | 面板必需文件,静态部署必然公开,含真实 URL / 内网主机端口 | **私有配置分层**:公开文件只写 `id/name/group`,真实地址放仓库根的 `local.monitors.json`(已 gitignore),探针按 `id` 合并覆盖 |
| `status.json` / `/api/status` | 探针输出,Pages 部署会公开,`detail` 可能含地址、版本、错误码 | `settings.redactDetails: true` → detail 只保留「正常/异常」;TCP 详情已默认不含地址;UptimeRobot 镜像目标的地址在 `hideTargets` 开启时不下发 | | 通知凭据 | SMTP 密码、MeoW 昵称(等同密钥)泄露 → 被冒名发信/推送 | 与地址一样只放 `local.monitors.json` 或环境变量;`public/monitors.json` 中 `notify.channels.*` 保持 `enabled: false` |
| `status.json` / `/api/status` | 探针输出,Pages 部署会公开,`detail` 可能含地址、版本、错误码 | `settings.redactDetails: true` → detail 只保留「正常/异常」;TCP 详情已默认不含地址;UptimeRobot 镜像目标的地址在 `hideTargets` 开启时不下发;事件时间线在 `redactDetails` 下只保留类型与时间 |
| 面板渲染 | 直接把地址、错误详情展示给访客 | `settings.hideTargets: true` → 不渲染任何地址(含 tooltip),显示「已隐藏」 | | 面板渲染 | 直接把地址、错误详情展示给访客 | `settings.hideTargets: true` → 不渲染任何地址(含 tooltip),显示「已隐藏」 |
| UptimeRobot API Key | 泄露后可读取(只读 Key 不能改配置,但会暴露监控清单) | 只放 `local.monitors.json`(gitignore)或环境变量 `UPTIMEROBOT_API_KEY`;`public/monitors.json` 里 `apiKey` 必须留空 | | UptimeRobot API Key | 泄露后可读取(只读 Key 不能改配置,但会暴露监控清单) | 只放 `local.monitors.json`(gitignore)或环境变量 `UPTIMEROBOT_API_KEY`;`public/monitors.json` 里 `apiKey` 必须留空 |
| `/api/v2/getMonitors`(兼容接口) | 无鉴权,暴露全部目标与可用率 | 内容与公开面板一致,故默认开放;如需收敛请在其前方加带认证的代理,或设 `allowRefresh: false` 并只暴露静态快照 | | `/api/v2/getMonitors`(兼容接口) | 无鉴权,暴露全部目标与可用率 | 内容与公开面板一致,故默认开放;如需收敛请在其前方加带认证的代理,或设 `allowRefresh: false` 并只暴露静态快照 |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 261 KiB

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 425 KiB

After

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 426 KiB

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 261 KiB

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

After

Width:  |  Height:  |  Size: 213 KiB

+1 -1
View File
@@ -11,7 +11,7 @@
import { connect } from 'cloudflare:sockets'; import { connect } from 'cloudflare:sockets';
const VERSION = '0.9.1'; const VERSION = '0.10.0';
const HISTORY_LIMIT = 48; const HISTORY_LIMIT = 48;
const UPTIME_DAYS = 90; const UPTIME_DAYS = 90;
const USER_AGENT = 'shiroko-monitor/0.3 (lightweight uptime panel)'; const USER_AGENT = 'shiroko-monitor/0.3 (lightweight uptime panel)';
+269 -3
View File
@@ -20,6 +20,8 @@ import { probeHttp } from './lib/http-probe.js';
import { probeTcp } from './lib/tcp-probe.js'; import { probeTcp } from './lib/tcp-probe.js';
import { probeBanner } from './lib/banner-probe.js'; import { probeBanner } from './lib/banner-probe.js';
import { fetchUptimeRobotMonitors } from './lib/uptimerobot.js'; import { fetchUptimeRobotMonitors } from './lib/uptimerobot.js';
import { notifyAll, sendTestNotifications, createMeoWLimiter } from './lib/notify.js';
import { probeCertificate, tlsTargetOf } from './lib/tls-probe.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '../..'); const ROOT = path.resolve(__dirname, '../..');
@@ -35,13 +37,15 @@ const CONCURRENCY = 8;
const WRITE_DEBOUNCE_MS = 300; const WRITE_DEBOUNCE_MS = 300;
const DEFAULT_INTERVAL_SEC = 60; const DEFAULT_INTERVAL_SEC = 60;
const PORT = Number(process.env.PORT || 8787); const PORT = Number(process.env.PORT || 8787);
const VERSION = '0.9.1'; const VERSION = '0.10.0';
const UR_DEFAULT_POLL_SEC = 300; const UR_DEFAULT_POLL_SEC = 300;
const UR_MIN_REFRESH_GAP_MS = 60000; const UR_MIN_REFRESH_GAP_MS = 60000;
const argv = process.argv.slice(2); const argv = process.argv.slice(2);
const ONCE = argv.includes('--once'); const ONCE = argv.includes('--once');
const DEMO = argv.includes('--demo'); const DEMO = argv.includes('--demo');
// --test-notify:只给各通知渠道发一条自检消息后退出(不探测、不写快照)
const TEST_NOTIFY = argv.includes('--test-notify');
// --out=<path> 或 --out <path>:指定快照输出路径(默认 public/status.json) // --out=<path> 或 --out <path>:指定快照输出路径(默认 public/status.json)
function argValue(name) { function argValue(name) {
@@ -54,6 +58,10 @@ const OUT_PATH = path.resolve(argValue('--out') || STATUS_PATH);
// --config / --local-config:多实例或测试时指定配置文件(默认 public/monitors.json 与仓库根的私有覆盖) // --config / --local-config:多实例或测试时指定配置文件(默认 public/monitors.json 与仓库根的私有覆盖)
const CONFIG_OVERRIDE = argValue('--config'); const CONFIG_OVERRIDE = argValue('--config');
const LOCAL_CONFIG_OVERRIDE = argValue('--local-config'); const LOCAL_CONFIG_OVERRIDE = argValue('--local-config');
// --data-dir=<path>:通知状态与事件时间线的存放目录(默认 ./data,也可用环境变量 SHIROKO_DATA_DIR)
if (argValue('--data-dir')) process.env.SHIROKO_DATA_DIR = argValue('--data-dir');
// 私有数据目录:通知状态(告警去抖)与事件时间线,含本机运行痕迹,默认不入库
const DATA_DIR = process.env.SHIROKO_DATA_DIR ? path.resolve(process.env.SHIROKO_DATA_DIR) : path.join(ROOT, 'data');
const ACTIVE_CONFIG_PATH = CONFIG_OVERRIDE ? path.resolve(CONFIG_OVERRIDE) : CONFIG_PATH; const ACTIVE_CONFIG_PATH = CONFIG_OVERRIDE ? path.resolve(CONFIG_OVERRIDE) : CONFIG_PATH;
const ACTIVE_LOCAL_CONFIG_PATH = LOCAL_CONFIG_OVERRIDE ? path.resolve(LOCAL_CONFIG_OVERRIDE) : LOCAL_CONFIG_PATH; const ACTIVE_LOCAL_CONFIG_PATH = LOCAL_CONFIG_OVERRIDE ? path.resolve(LOCAL_CONFIG_OVERRIDE) : LOCAL_CONFIG_PATH;
@@ -308,7 +316,208 @@ function memoryStats() {
return { rssMB: mb(m.rss), heapUsedMB: mb(m.heapUsed), heapTotalMB: mb(m.heapTotal) }; return { rssMB: mb(m.rss), heapUsedMB: mb(m.heapUsed), heapTotalMB: mb(m.heapTotal) };
} }
/* ---------- 数据源过滤 ---------- */ /* ---------- 通知(邮件 / MeoW)、事件时间线、维护窗口与证书监控 ---------- */
const DEFAULT_TLS_CHECK_SEC = 21600; // 证书默认 6 小时查一次
const TLS_ALERT_DAYS = [14, 7, 3];
const EVENT_LIMIT = 500;
const EVENT_IN_SNAPSHOT = 20;
function notifyConfig(config) {
const n = config.settings?.notify ?? {};
const channels = n.channels ?? {};
return {
enabled: n.enabled === true && Object.values(channels).some((c) => c?.enabled === true),
failureThreshold: Math.max(1, Number(n.failureThreshold) || 2),
recoveryNotify: n.recoveryNotify !== false,
quietHours: n.quietHours ?? null,
channels,
};
}
function tlsConfig(config) {
const t = config.settings?.tls ?? {};
const alertDays = Array.isArray(t.alertDays) ? t.alertDays.map(Number).filter(Number.isFinite) : null;
return {
enabled: t.enabled !== false, // 默认对 https 目标开启
checkIntervalSec: Math.max(600, Number(t.checkIntervalSec) || DEFAULT_TLS_CHECK_SEC),
alertDays: alertDays?.length ? alertDays.sort((a, b) => b - a) : TLS_ALERT_DAYS,
};
}
// 维护窗口:{ from, to, id?: string|string[], note? };不写 id 表示对所有目标生效
function activeMaintenanceFor(config, id, at = new Date()) {
const list = config.settings?.maintenance;
if (!Array.isArray(list) || !list.length) return null;
const t = at.getTime();
for (const w of list) {
const from = Date.parse(w.from);
const to = Date.parse(w.to);
if (!Number.isFinite(from) || !Number.isFinite(to) || t < from || t > to) continue;
if (!w.id) return w;
const ids = Array.isArray(w.id) ? w.id : [w.id];
if (ids.includes(id)) return w;
}
return null;
}
function readJson(file, fallback) {
try {
return JSON.parse(fs.readFileSync(file, 'utf8'));
} catch {
return fallback;
}
}
function writeJson(file, data) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
}
const NOTIFY_STATE_PATH = () => path.join(DATA_DIR, 'notify-state.json');
const EVENTS_PATH = () => path.join(DATA_DIR, 'events.json');
function monitorNotifyState(runtime, id) {
const list = runtime.notifyState.monitors;
if (!list[id]) list[id] = { lastStatus: null, failStreak: 0, notifiedTls: [], tlsValidTo: null };
return list[id];
}
function appendEvents(runtime, entries) {
if (!entries.length) return;
runtime.events = [...runtime.events, ...entries].slice(-EVENT_LIMIT);
writeJson(EVENTS_PATH(), runtime.events);
}
// 通知 + 事件:每轮探测结束后评估状态翻转(带去抖)、恢复、维护窗口抑制
async function evaluateRound(runtime) {
const notify = notifyConfig(runtime.config);
const events = [];
const pendingNotifications = [];
for (const m of runtime.config.monitors) {
const r = runtime.state.get(m.id)?.lastResult;
if (!r || r.status === 'pending') continue;
const st = monitorNotifyState(runtime, m.id);
st.failStreak = r.status === 'down' ? (st.failStreak ?? 0) + 1 : 0;
const maintenance = activeMaintenanceFor(runtime.config, m.id);
const at = new Date().toISOString();
if (r.status === 'down') {
// 去抖:连续失败达到阈值才算确认故障(阈值 1 表示立即告警)
if (st.lastStatus === 'down' || st.failStreak < notify.failureThreshold) continue;
st.lastStatus = 'down';
st.lastNotifiedAt = at;
events.push({ t: at, id: m.id, name: m.name, type: 'down', detail: r.detail ?? null, maintenance: Boolean(maintenance) });
if (!maintenance) {
pendingNotifications.push({
subject: `【故障】${m.name} 连接失败`,
text: `目标:${m.name}\n地址:${m.url ?? `${m.host}:${m.port}`}\n时间:${at}\n详情:${r.detail ?? '无'}\n连续失败:${st.failStreak} 次\n\n—— shiroko-monitor`,
});
}
} else if (r.status === 'up') {
if (st.lastStatus === null) {
st.lastStatus = 'up'; // 首次观测不产生「恢复」噪音
continue;
}
if (st.lastStatus === 'down') {
st.lastStatus = 'up';
st.lastNotifiedAt = at;
events.push({ t: at, id: m.id, name: m.name, type: 'up', detail: '已恢复', maintenance: Boolean(maintenance) });
if (notify.recoveryNotify && !maintenance) {
pendingNotifications.push({
subject: `【恢复】${m.name} 已恢复正常`,
text: `目标:${m.name}\n地址:${m.url ?? `${m.host}:${m.port}`}\n恢复时间:${at}\n延迟:${r.latency ?? '—'} ms\n\n—— shiroko-monitor`,
});
}
}
}
}
if (events.length) {
appendEvents(runtime, events);
console.log(`[events] 新增 ${events.length} 条:${events.map((e) => `${e.name} ${e.type}`).join('、')}`);
}
// 汇总成一条通知,避免同一轮多点故障刷屏
if (pendingNotifications.length) {
const message = pendingNotifications.length === 1
? pendingNotifications[0]
: {
subject: `【${pendingNotifications.length} 个目标状态变化】shiroko-monitor`,
text: pendingNotifications.map((p) => p.text).join('\n\n──────────\n\n'),
};
const res = await notifyAll(notify, message, { logger: console });
runtime.notifyState.lastResult = { at: new Date().toISOString(), ...res };
}
// 无论是否发通知都要落盘:否则「连续失败计数」会丢,重启或下一轮就达不到阈值
saveNotifyState(runtime);
}
// 证书检查:按 checkIntervalSec 逐个握手,跨阈值时告警(同一张证书每个阈值只告一次)
async function checkCertificates(runtime) {
const tlsCfg = tlsConfig(runtime.config);
if (!tlsCfg.enabled) return;
const now = Date.now();
const events = [];
const messages = [];
for (const m of runtime.config.monitors) {
const target = tlsTargetOf(m);
if (!target) continue;
const prev = runtime.tls.get(m.id);
if (prev && now - (prev.checkedAtMs ?? 0) < tlsCfg.checkIntervalSec * 1000) continue;
const result = await probeCertificate({ ...target, timeoutMs: Math.max(5, Number(m.timeout) || 8) * 1000 });
runtime.tls.set(m.id, { ...result, checkedAt: new Date().toISOString(), checkedAtMs: now });
const st = monitorNotifyState(runtime, m.id);
if (result.error) {
console.warn(`[tls] ${m.id} 证书检查失败:${result.error}`);
continue;
}
// 换了证书(validTo 变化)→ 重置已通知阈值,新证书重新提醒
if (st.tlsValidTo !== result.validTo) {
st.tlsValidTo = result.validTo;
st.notifiedTls = [];
}
const maintenance = activeMaintenanceFor(runtime.config, m.id);
// 已过期的证书只报最严重的一档(避免一次刷三条)
const thresholds = result.daysLeft <= 0 ? tlsCfg.alertDays.slice(-1) : tlsCfg.alertDays;
for (const threshold of thresholds) {
if (result.daysLeft > threshold || (st.notifiedTls ?? []).includes(threshold)) continue;
st.notifiedTls = [...(st.notifiedTls ?? []), threshold];
const at = new Date().toISOString();
events.push({ t: at, id: m.id, name: m.name, type: 'tls', detail: `证书剩余 ${result.daysLeft} 天(阈值 ${threshold} 天)`, maintenance: Boolean(maintenance) });
if (!maintenance) {
messages.push(`- ${m.name}:证书剩余 ${result.daysLeft} 天(到期 ${result.validTo.slice(0, 10)},签发者 ${result.issuer ?? '未知'})`);
}
}
}
if (events.length) {
appendEvents(runtime, events);
if (messages.length) {
const res = await notifyAll(notifyConfig(runtime.config), {
subject: `【证书提醒】${messages.length} 个证书即将到期`,
text: `${messages.join('\n')}\n\n签发者与到期时间取自实际握手,请及时续期。\n\n—— shiroko-monitor`,
}, { logger: console });
runtime.notifyState.lastResult = { at: new Date().toISOString(), ...res };
}
saveNotifyState(runtime);
}
}
function saveNotifyState(runtime) {
writeJson(NOTIFY_STATE_PATH(), runtime.notifyState);
}
// 通知与证书检查串行执行:避免并发轮次重复发同一条告警
function queueEvaluation(runtime, task) {
runtime.notifyChain = (runtime.notifyChain ?? Promise.resolve())
.then(() => task(runtime))
.catch((err) => console.error(`[notify] 评估异常:${err.stack || err.message}`));
return runtime.notifyChain;
}
// 只影响「展示与快照输出」,不影响探测:配置文件里的目标始终按各自 interval 探测 // 只影响「展示与快照输出」,不影响探测:配置文件里的目标始终按各自 interval 探测
function dataFilter(config) { function dataFilter(config) {
@@ -415,6 +624,14 @@ function snapshot(runtime, demo = false) {
}; };
entry.uptimeRatios = s.uptimeRatios ?? null; entry.uptimeRatios = s.uptimeRatios ?? null;
entry.downSeconds = s.downSeconds ?? null; entry.downSeconds = s.downSeconds ?? null;
} else {
// 证书与维护窗口信息(来自独立的握手检查与配置),供面板打标
const tlsInfo = runtime.tls?.get(id);
if (tlsInfo) entry.tls = redact
? { daysLeft: tlsInfo.daysLeft ?? null, error: tlsInfo.error ? '不可用' : null }
: { daysLeft: tlsInfo.daysLeft ?? null, validTo: tlsInfo.validTo ?? null, issuer: tlsInfo.issuer ?? null, checkedAt: tlsInfo.checkedAt ?? null, error: tlsInfo.error ?? null };
const maintenance = activeMaintenanceFor(config, id);
if (maintenance) entry.maintenance = { note: maintenance.note ?? null, to: maintenance.to ?? null };
} }
monitors[id] = entry; monitors[id] = entry;
} }
@@ -438,6 +655,20 @@ function snapshot(runtime, demo = false) {
memory: memoryStats(), memory: memoryStats(),
}, },
sources: { uptimerobot: { ...(runtime.ur ?? {}) } }, sources: { uptimerobot: { ...(runtime.ur ?? {}) } },
// 通知能力与最近事件(事件含目标名与原因,redactDetails 时只保留类型与时间)
notify: (() => {
const n = notifyConfig(config);
return {
enabled: n.enabled,
channels: Object.entries(n.channels ?? {}).filter(([, c]) => c?.enabled).map(([k]) => k),
quietHours: n.quietHours,
failureThreshold: n.failureThreshold,
lastResult: runtime.notifyState?.lastResult ?? null,
};
})(),
events: (runtime.events ?? []).slice(-EVENT_IN_SNAPSHOT).reverse().map((e) => (redact
? { t: e.t, id: e.id, type: e.type, name: e.name, detail: e.type === 'down' ? '异常' : e.type === 'up' ? '恢复' : e.detail, maintenance: e.maintenance }
: e)),
monitors, monitors,
}; };
} }
@@ -486,6 +717,7 @@ function forceRound(runtime) {
await settleInflight(runtime.state); await settleInflight(runtime.state);
runtime.forcedAt = Date.now(); runtime.forcedAt = Date.now();
scheduleWrite(runtime); scheduleWrite(runtime);
queueEvaluation(runtime, evaluateRound);
} catch (err) { } catch (err) {
console.error(`[refresh] 强制探测异常:${err.message}`); console.error(`[refresh] 强制探测异常:${err.message}`);
} finally { } finally {
@@ -654,6 +886,12 @@ function createServer(runtime) {
return sendJson(res, toUptimeRobotResponse(runtime)); return sendJson(res, toUptimeRobotResponse(runtime));
} }
// 事件时间线:面板「事件」视图取这里(含通知去抖前的全部状态翻转记录)
if (url.pathname === '/api/events') {
const list = [...(runtime.events ?? [])].reverse();
return sendJson(res, { total: list.length, events: list.slice(0, 200) });
}
let rel = decodeURIComponent(url.pathname); let rel = decodeURIComponent(url.pathname);
if (rel === '/') rel = '/index.html'; if (rel === '/') rel = '/index.html';
const file = path.join(PUBLIC_DIR, path.normalize(rel)); const file = path.join(PUBLIC_DIR, path.normalize(rel));
@@ -688,8 +926,26 @@ async function main() {
config: loadConfig(), config: loadConfig(),
state: null, state: null,
dataUpdatedAt: Date.now(), dataUpdatedAt: Date.now(),
// 通知去抖状态与事件时间线从私有数据目录恢复,重启不会重复告警
notifyState: readJson(NOTIFY_STATE_PATH(), { monitors: {} }),
events: readJson(EVENTS_PATH(), []),
tls: new Map(),
tlsLastCheckMs: 0,
ur: { enabled: false, ok: null, error: null, lastSyncAt: null, count: 0, syncing: false, lastSyncMs: 0 }, ur: { enabled: false, ok: null, error: null, lastSyncAt: null, count: 0, syncing: false, lastSyncMs: 0 },
}; };
if (TEST_NOTIFY) {
// 通道自检:只发一条测试消息,不探测、不写快照
const cfg = notifyConfig(runtime.config);
if (!cfg.enabled) {
console.error('未启用任何通知渠道(settings.notify.enabled 或 channels.*.enabled 未开启)');
process.exitCode = 1;
return;
}
console.log('通知通道自检:');
const out = await sendTestNotifications(cfg, { logger: console });
if (out.some((r) => !r.ok)) process.exitCode = 1;
return;
}
if (DEMO) { if (DEMO) {
writeDemo(runtime.config); writeDemo(runtime.config);
return; return;
@@ -700,6 +956,8 @@ async function main() {
if (ONCE) { if (ONCE) {
await probeDue(runtime, true); await probeDue(runtime, true);
await queueEvaluation(runtime, evaluateRound);
await queueEvaluation(runtime, checkCertificates);
writeStatus(snapshot(runtime)); writeStatus(snapshot(runtime));
const s = snapshot(runtime).stats; const s = snapshot(runtime).stats;
console.log(`[once] 完成:共 ${s.total},在线 ${s.up},离线 ${s.down}`); console.log(`[once] 完成:共 ${s.total},在线 ${s.up},离线 ${s.down}`);
@@ -725,7 +983,10 @@ async function main() {
setInterval(() => { setInterval(() => {
probeDue(runtime) probeDue(runtime)
.then(() => scheduleWrite(runtime)) .then(() => {
scheduleWrite(runtime);
queueEvaluation(runtime, evaluateRound);
})
.catch((err) => console.error(`[tick] ${err.stack || err}`)); .catch((err) => console.error(`[tick] ${err.stack || err}`));
// UptimeRobot 按自身 pollIntervalSec 拉取,失败不影响本地巡检 // UptimeRobot 按自身 pollIntervalSec 拉取,失败不影响本地巡检
if (Date.now() - runtime.ur.lastSyncMs >= uptimeRobotConfig(runtime.config).pollIntervalSec * 1000) { if (Date.now() - runtime.ur.lastSyncMs >= uptimeRobotConfig(runtime.config).pollIntervalSec * 1000) {
@@ -733,6 +994,11 @@ async function main() {
.then(() => scheduleWrite(runtime)) .then(() => scheduleWrite(runtime))
.catch(() => { /* syncUptimeRobot 内部已记录错误 */ }); .catch(() => { /* syncUptimeRobot 内部已记录错误 */ });
} }
// 证书到期检查:按 checkIntervalSec 周期执行(默认 6 小时),握手失败只记录不影响巡检
if (Date.now() - runtime.tlsLastCheckMs >= tlsConfig(runtime.config).checkIntervalSec * 1000) {
runtime.tlsLastCheckMs = Date.now();
queueEvaluation(runtime, checkCertificates);
}
}, 1000); }, 1000);
createServer(runtime).listen(PORT, () => { createServer(runtime).listen(PORT, () => {
+324
View File
@@ -0,0 +1,324 @@
/**
* 通知发送:邮件(手写 SMTP,零依赖)+ MeoW 推送
*
* 为什么手写 SMTP:本项目刻意保持零运行时依赖(Node 内置模块够用),
* 引入 nodemailer 会带来一串传递依赖,对一个自用面板不划算。
* 只实现发信必需的流程:EHLO → (STARTTLS) → AUTH → MAIL/RCPT/DATA → QUIT。
*
* MeoW 接口要点(据 https://www.chuckfang.com/MeoW/api_doc.html ):
* - 无需 token,昵称即寻址:POST https://api.chuckfang.com/{昵称}
* - HTTP 状态恒为 200,成功与否看响应体的 status 字段
* - msgType / htmlHeight 是查询参数,不是 body 参数
* - 非会员限流:3 秒 1 条、每分钟 15 条、每小时 60 条
* - 官方提醒:不要用多人共享的出口 IP(CI 环境建议只用邮件)
*/
import net from 'node:net';
import tls from 'node:tls';
const CRLF = '\r\n';
/* ---------- 邮件 ---------- */
function encodeHeader(value) {
// 非 ASCII 主题按 RFC 2047 base64 编码,避免中文主题乱码
return /^[\x20-\x7E]*$/.test(value) ? value : `=?UTF-8?B?${Buffer.from(value, 'utf8').toString('base64')}?=`;
}
function base64Body(text) {
// 正文用 base64:既免去 quoted-printable 的编码复杂度,也不会出现以 "." 开头的行(无需 dot-stuffing)
return (Buffer.from(text, 'utf8').toString('base64').match(/.{1,76}/g) ?? []).join(CRLF);
}
function buildMessage({ from, to, subject, text }) {
return [
`From: ${from}`,
`To: ${to.join(', ')}`,
`Subject: ${encodeHeader(subject)}`,
`Date: ${new Date().toUTCString()}`,
'MIME-Version: 1.0',
'Content-Type: text/plain; charset=UTF-8',
'Content-Transfer-Encoding: base64',
'',
base64Body(text),
'',
].join(CRLF);
}
/** 极简 SMTP 会话:读 3 位状态码响应(支持多行 `250-` 连续响应) */
function createSession(socket, timeoutMs) {
let buffer = '';
let waiter = null;
socket.setEncoding('utf8');
socket.on('data', (chunk) => {
buffer += chunk;
if (!waiter) return;
// 一条完整响应以 `\d{3} `(空格结尾)结束
const lines = buffer.split(CRLF);
const complete = lines.some((l) => /^\d{3} /.test(l));
if (complete) {
const reply = buffer;
buffer = '';
const w = waiter;
waiter = null;
w.resolve(reply);
}
});
const fail = (err) => {
if (waiter) {
const w = waiter;
waiter = null;
w.reject(err);
}
};
socket.on('error', fail);
socket.on('close', () => fail(new Error('SMTP 连接被关闭')));
// 空闲超时必须主动断开:否则服务器不响应时读响应会一直挂着,把探针循环卡死
socket.on('timeout', () => {
socket.destroy();
fail(new Error(`SMTP 响应超时(${timeoutMs}ms)`));
});
return {
read: () => new Promise((resolve, reject) => {
const timer = setTimeout(() => {
waiter = null;
reject(new Error(`SMTP 响应超时(${timeoutMs}ms)`));
}, timeoutMs);
waiter = {
resolve: (v) => { clearTimeout(timer); resolve(v); },
reject: (e) => { clearTimeout(timer); reject(e); },
};
}),
write: (line) => socket.write(line + CRLF),
code: (reply) => Number(reply.slice(0, 3)),
raw: () => socket,
};
}
async function connect(socket) {
await new Promise((resolve, reject) => {
socket.once('connect', resolve);
socket.once('secureConnect', resolve);
socket.once('error', reject);
});
return socket;
}
export async function sendEmail(cfg, { subject, text }, { timeoutMs = 15000 } = {}) {
const host = cfg.host;
const port = Number(cfg.port) || 465;
if (!host) throw new Error('邮件通道缺少 host');
if (!cfg.from) throw new Error('邮件通道缺少发件人 from');
const to = Array.isArray(cfg.to) ? cfg.to.filter(Boolean) : [cfg.to].filter(Boolean);
if (!to.length) throw new Error('邮件通道缺少收件人 to');
// 465 走隐式 TLS;587/25 先明文再 STARTTLS
const implicitTls = port === 465 || cfg.secure === true;
let socket = implicitTls
? tls.connect({ host, port, servername: cfg.servername || host, rejectUnauthorized: cfg.rejectUnauthorized !== false })
: net.connect({ host, port });
socket.setTimeout(timeoutMs);
await connect(socket);
let session = createSession(socket, timeoutMs);
const expect = async (wanted) => {
const reply = await session.read();
const code = session.code(reply);
if (code !== wanted) throw new Error(`SMTP 期望 ${wanted} 但收到:${reply.trim().split(CRLF).pop()}`);
return reply;
};
await expect(220);
session.write(`EHLO ${cfg.helo || 'shiroko-monitor'}`);
let caps = await expect(250);
if (!implicitTls) {
if (/STARTTLS/i.test(caps)) {
session.write('STARTTLS');
await expect(220);
socket = await new Promise((resolve, reject) => {
const upgraded = tls.connect({ socket, servername: cfg.servername || host, rejectUnauthorized: cfg.rejectUnauthorized !== false }, () => resolve(upgraded));
upgraded.once('error', reject);
});
socket.setTimeout(timeoutMs);
session = createSession(socket, timeoutMs);
session.write(`EHLO ${cfg.helo || 'shiroko-monitor'}`);
caps = await expect(250);
} else if (cfg.allowInsecure !== true) {
// 默认拒绝明文发信(凭据会裸奔);仅测试或本机可信中继场景下显式开启
throw new Error('SMTP 服务器不支持 STARTTLS,已中止(如需明文传输请显式设置 allowInsecure: true)');
}
}
if (cfg.user) {
if (/AUTH[ =-].*PLAIN/i.test(caps) && !/AUTH[ =-].*LOGIN/i.test(caps)) {
session.write(`AUTH PLAIN ${Buffer.from(`\0${cfg.user}\0${cfg.pass ?? ''}`, 'utf8').toString('base64')}`);
} else {
session.write('AUTH LOGIN');
await expect(334);
session.write(Buffer.from(cfg.user, 'utf8').toString('base64'));
await expect(334);
session.write(Buffer.from(cfg.pass ?? '', 'utf8').toString('base64'));
}
await expect(235);
}
session.write(`MAIL FROM:<${cfg.from}>`);
await expect(250);
for (const rcpt of to) {
session.write(`RCPT TO:<${rcpt}>`);
const reply = await session.read();
if (![250, 251].includes(session.code(reply))) throw new Error(`收件人被拒(${rcpt}):${reply.trim()}`);
}
session.write('DATA');
await expect(354);
socket.write(buildMessage({ from: cfg.from, to, subject, text }) + CRLF + '.' + CRLF);
await expect(250);
session.write('QUIT');
socket.end();
return { channel: 'email', ok: true, detail: `已投递至 ${to.join(', ')}` };
}
/* ---------- MeoW ---------- */
export async function sendMeoW(cfg, { title, text }, { timeoutMs = 12000, fetchImpl = fetch } = {}) {
const nickname = cfg.nickname;
if (!nickname) throw new Error('MeoW 通道缺少 nickname');
if (nickname.includes('/')) throw new Error('MeoW 昵称不允许包含斜杠');
const base = (cfg.baseUrl || 'https://api.chuckfang.com').replace(/\/+$/, '');
const params = new URLSearchParams();
if (cfg.msgType && cfg.msgType !== 'text') {
params.set('msgType', cfg.msgType);
if (cfg.htmlHeight) params.set('htmlHeight', String(cfg.htmlHeight));
}
if (cfg.url) params.set('url', cfg.url);
const query = params.toString() ? `?${params}` : '';
const res = await fetchImpl(`${base}/${encodeURIComponent(nickname)}${query}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title, msg: text }),
signal: AbortSignal.timeout(timeoutMs),
});
let payload = null;
try {
payload = await res.json();
} catch {
throw new Error(`MeoW 响应不是 JSON(HTTP ${res.status})`);
}
// 注意:HTTP 状态恒为 200,必须以响应体的 status 为准
const status = Number(payload?.status);
if (status !== 200) {
const reason = payload?.message || payload?.msg || '未知错误';
const hint = status === 429 ? '(触发频率限制:3 秒 1 条 / 15 每分钟 / 60 每小时)'
: status === 404 ? '(昵称未注册)'
: status === 403 ? '(内容被策略拦截)' : '';
throw new Error(`MeoW 推送失败 status=${status} ${reason}${hint}`);
}
return { channel: 'meow', ok: true, detail: payload.message || payload.msg || '推送成功' };
}
/* ---------- 调度 ---------- */
export function parseQuietHours(spec) {
if (!spec) return null;
const m = /^(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})$/.exec(String(spec).trim());
if (!m) return null;
const [from, to] = [Number(m[1]) * 60 + Number(m[2]), Number(m[3]) * 60 + Number(m[4])];
return { from, to };
}
export function inQuietHours(spec, date = new Date()) {
const range = parseQuietHours(spec);
if (!range) return false;
const now = date.getHours() * 60 + date.getMinutes();
// 跨零点(如 23:00-07:00)
return range.from <= range.to ? now >= range.from && now < range.to : now >= range.from || now < range.to;
}
/** MeoW 客户端限流:保守遵守 3 秒 1 条 / 每分钟 15 条 / 每小时 60 条 */
export function createMeoWLimiter(now = () => Date.now()) {
let history = [];
return {
allow() {
const t = now();
history = history.filter((x) => t - x < 3600_000);
const last = history.length ? history.at(-1) : Number.NEGATIVE_INFINITY; // 空历史不能当成 0,否则首条必被拦
const inMinute = history.filter((x) => t - x < 60_000).length;
if (t - last < 3000 || inMinute >= 15 || history.length >= 60) return false;
return true;
},
mark() {
history.push(now());
},
state: () => history.length,
};
}
const meowLimiter = createMeoWLimiter();
/**
* 按配置把一条通知发到所有启用的渠道。
* 静默时段内不发送,但返回 suppressed 标记(调用方仍需记录事件)。
*/
export async function notifyAll(cfg, message, { logger = console, fetchImpl = fetch, now = () => Date.now() } = {}) {
if (!cfg?.enabled) return { suppressed: false, skipped: 'notify-disabled', results: [] };
if (inQuietHours(cfg.quietHours)) {
logger.log?.(`[notify] 静默时段(${cfg.quietHours}),本条不发送:${message.subject}`);
return { suppressed: true, results: [] };
}
const results = [];
const channels = cfg.channels ?? {};
if (channels.email?.enabled) {
try {
results.push(await sendEmail(channels.email, message));
} catch (err) {
results.push({ channel: 'email', ok: false, error: err.message });
}
}
if (channels.meow?.enabled) {
if (!meowLimiter.allow()) {
results.push({ channel: 'meow', ok: false, error: '本地限流:为避免触发 MeoW 频率限制,本条已跳过', rateLimited: true });
} else {
try {
meowLimiter.mark();
results.push(await sendMeoW(channels.meow, message, { fetchImpl }));
} catch (err) {
results.push({ channel: 'meow', ok: false, error: err.message });
}
}
}
const failed = results.filter((r) => !r.ok);
if (failed.length) logger.error?.(`[notify] 部分渠道发送失败:${failed.map((f) => `${f.channel}: ${f.error}`).join(' | ')}`);
if (!failed.length && results.length) logger.log?.(`[notify] 已发送:${message.subject}`);
return { suppressed: false, results };
}
/** 自检:给每个启用的渠道发一条测试消息(不经过静默时段判定) */
export async function sendTestNotifications(cfg, { logger = console, fetchImpl = fetch } = {}) {
const channels = cfg?.channels ?? {};
const subject = 'shiroko-monitor 通知自检';
const text = `这是一条通道自检消息,收到即表示该渠道配置可用。\n时间:${new Date().toLocaleString('zh-CN')}`;
const out = [];
if (channels.email?.enabled) {
try {
out.push(await sendEmail(channels.email, { subject, text }));
} catch (err) {
out.push({ channel: 'email', ok: false, error: err.message });
}
} else {
out.push({ channel: 'email', ok: false, error: '未启用(settings.notify.channels.email.enabled = false)' });
}
if (channels.meow?.enabled) {
try {
out.push(await sendMeoW(channels.meow, { title: subject, text }, { fetchImpl }));
} catch (err) {
out.push({ channel: 'meow', ok: false, error: err.message });
}
} else {
out.push({ channel: 'meow', ok: false, error: '未启用(settings.notify.channels.meow.enabled = false)' });
}
for (const r of out) logger.log?.(` ${r.ok ? '✓' : '✗'} ${r.channel}:${r.ok ? r.detail : r.error}`);
return out;
}
+62
View File
@@ -0,0 +1,62 @@
/**
* TLS 证书探测:只做一次握手取 notAfter,用于「证书还有多久过期」提醒。
*
* 为什么不复用 HTTP 探测:现有探测走 fetch(undici),拿不到对端证书;
* 单独握手更轻,也不影响既有探测路径与判定逻辑。
* rejectUnauthorized 设为 false:证书已过期/不受信时也要能读到有效期,
* 是否通过校验由 socket.authorized 反映。
*/
import tls from 'node:tls';
export async function probeCertificate({ host, port = 443, servername, timeoutMs = 8000 }) {
if (!host) return { error: '缺少主机名' };
return new Promise((resolve) => {
let settled = false;
const finish = (result) => {
if (settled) return;
settled = true;
try {
socket.destroy();
} catch { /* 忽略 */ }
resolve(result);
};
const socket = tls.connect(
{ host, port, servername: servername || host, rejectUnauthorized: false, timeout: timeoutMs },
() => {
let cert;
try {
cert = socket.getPeerCertificate();
} catch (err) {
return finish({ error: `读取证书失败:${err.message}` });
}
if (!cert || !cert.valid_to) return finish({ error: '未取到证书有效期' });
const validTo = new Date(cert.valid_to);
if (Number.isNaN(validTo.getTime())) return finish({ error: `证书有效期无法解析:${cert.valid_to}` });
finish({
error: null,
validTo: validTo.toISOString(),
daysLeft: Math.floor((validTo.getTime() - Date.now()) / 86_400_000),
issuer: cert.issuer?.O || cert.issuer?.CN || null,
subject: cert.subject?.CN || null,
authorized: socket.authorized === true,
});
},
);
socket.on('error', (err) => finish({ error: err.message }));
socket.on('timeout', () => finish({ error: `TLS 握手超时(${timeoutMs}ms)` }));
});
}
/** 从监控目标推断是否需要/如何进行证书检查 */
export function tlsTargetOf(monitor) {
if (!monitor || monitor.tls === false) return null;
const url = monitor.url;
if (!url || !/^https:\/\//i.test(url)) return null;
try {
const parsed = new URL(url);
return { host: parsed.hostname, port: Number(parsed.port) || 443, servername: parsed.hostname };
} catch {
return null;
}
}
+38 -3
View File
@@ -1,4 +1,4 @@
const VERSION = '0.9.1'; const VERSION = '0.10.0';
const HB_LIMIT = 48; const HB_LIMIT = 48;
const UPTIME_DAYS = 90; const UPTIME_DAYS = 90;
const BROWSER_CONCURRENCY = 6; const BROWSER_CONCURRENCY = 6;
@@ -286,7 +286,7 @@ function renderCard(m) {
const head = document.createElement('div'); const head = document.createElement('div');
head.className = 'card-head'; head.className = 'card-head';
// 名称列在窄屏会被截断,title 保证鼠标悬停仍能看到完整名称 // 名称列在窄屏会被截断,title 保证鼠标悬停仍能看到完整名称
head.innerHTML = `<span class="dot"></span><h3 title="${escapeHtml(m.name)}">${escapeHtml(m.name)}</h3><span class="type-badge">${escapeHtml(typeLabel(m))}</span>`; head.innerHTML = `<span class="dot"></span><h3 title="${escapeHtml(m.name)}">${escapeHtml(m.name)}</h3><span class="type-badge">${escapeHtml(typeLabel(m))}</span>${certBadge(st)}${maintBadge(st)}`;
const target = document.createElement('div'); const target = document.createElement('div');
target.className = 'card-target'; target.className = 'card-target';
@@ -356,6 +356,25 @@ function isExternal(m) {
return m.source === 'uptimerobot'; return m.source === 'uptimerobot';
} }
// 证书徽标:仅在临近到期时出现(≤14 天黄色、≤7 天红色),避免常驻打扰
function certBadge(st) {
const t = st?.tls;
if (!t || t.daysLeft == null) return '';
if (t.error) return `<span class="tls-badge danger" title="证书检查失败:${escapeHtml(t.error)}">证书异常</span>`;
if (t.daysLeft > 14) return '';
const cls = t.daysLeft <= 7 ? 'danger' : 'warn';
const tip = `证书到期:${escapeHtml(String(t.validTo ?? '').slice(0, 10))}${t.issuer ? `(${escapeHtml(t.issuer)})` : ''}`;
return `<span class="tls-badge ${cls}" title="${tip}">证书 ${t.daysLeft} 天</span>`;
}
// 维护窗口徽标:该时段内不计入可用率统计、不发告警
function maintBadge(st) {
const m = st?.maintenance;
if (!m) return '';
const tip = `维护窗口${m.to ? `至 ${escapeHtml(String(m.to).replace('T', ' ').slice(0, 16))}` : ''}${m.note ? `:${escapeHtml(m.note)}` : ''}\n期间不计入可用率、不发告警`;
return `<span class="maint-badge" title="${tip}">维护中</span>`;
}
function renderTabs() { function renderTabs() {
const nav = $('group-tabs'); const nav = $('group-tabs');
const monitors = allMonitors(); const monitors = allMonitors();
@@ -469,7 +488,7 @@ function tableRow(m, hide) {
tr.dataset.id = m.id; tr.dataset.id = m.id;
const targetCell = hide ? '' : `<td class="t-target" title="${escapeHtml(targetText(m))}">${escapeHtml(targetText(m))}</td>`; tr.innerHTML = const targetCell = hide ? '' : `<td class="t-target" title="${escapeHtml(targetText(m))}">${escapeHtml(targetText(m))}</td>`; tr.innerHTML =
`<td class="t-state"><span class="dot"></span>${s.label}</td>` + `<td class="t-state"><span class="dot"></span>${s.label}</td>` +
`<td class="t-name" title="${escapeHtml(m.name)}">${escapeHtml(m.name)}<span class="type-badge">${escapeHtml(typeLabel(m))}</span></td>` + `<td class="t-name" title="${escapeHtml(m.name)}">${escapeHtml(m.name)}<span class="type-badge">${escapeHtml(typeLabel(m))}</span>${certBadge(st)}${maintBadge(st)}</td>` +
targetCell + targetCell +
`<td class="t-num">${st?.latency != null ? `${st.latency} ms` : '–'}</td>` + `<td class="t-num">${st?.latency != null ? `${st.latency} ms` : '–'}</td>` +
`<td class="t-num">${escapeHtml(resultText(st, m))}</td>` + `<td class="t-num">${escapeHtml(resultText(st, m))}</td>` +
@@ -613,6 +632,22 @@ function renderSummary() {
? `服务端数据最后变更:${fmtTime(state.status?.dataUpdatedAt)}(距现在 ${fmtRelative(state.status?.dataUpdatedAt)})\n快照生成:${fmtTime(st?.generatedAt)}\n服务端更新节奏:每 ${serverCadenceSec() ?? '—'} 秒` ? `服务端数据最后变更:${fmtTime(state.status?.dataUpdatedAt)}(距现在 ${fmtRelative(state.status?.dataUpdatedAt)})\n快照生成:${fmtTime(st?.generatedAt)}\n服务端更新节奏:每 ${serverCadenceSec() ?? '—'} 秒`
: '尚无服务端数据'; : '尚无服务端数据';
$('demo-chip').hidden = !st?.demo; $('demo-chip').hidden = !st?.demo;
// 通知渠道状态:有失败渠道时给出具体原因(点开 title 看)
const notify = st?.notify;
const nb = $('notify-badge');
if (nb) {
const failed = (notify?.lastResult?.results ?? []).filter((r) => !r.ok);
if (!notify?.enabled) {
nb.hidden = true;
} else {
nb.hidden = false;
nb.classList.toggle('warn-chip', failed.length > 0);
nb.textContent = `通知:${(notify.channels ?? []).join(' + ') || '无'}${notify.lastResult ? ` · 最近 ${fmtRelative(notify.lastResult.at)}` : ''}${failed.length ? ` · ${failed.length} 个渠道失败` : ''}`;
nb.title = failed.length
? `发送失败:${failed.map((r) => `${r.channel}: ${r.error}`).join('\n')}`
: (notify.lastResult ? `最近一次:${notify.lastResult.at},${(notify.lastResult.results ?? []).map((r) => `${r.channel} ${r.ok ? '成功' : '失败'}`).join('、')}` : '尚未发送过通知');
}
}
const ur = st?.sources?.uptimerobot; const ur = st?.sources?.uptimerobot;
const urChip = $('ur-chip'); const urChip = $('ur-chip');
if (urChip) { if (urChip) {
+16
View File
@@ -412,6 +412,22 @@ tr[data-status="pending"] .t-state { color: var(--pending-text); }
white-space: nowrap; white-space: nowrap;
} }
.t-num { font-variant-numeric: tabular-nums; white-space: nowrap; } .t-num { font-variant-numeric: tabular-nums; white-space: nowrap; }
/* 证书 / 维护徽标:出现在名称旁,不占独立列 */
.tls-badge, .maint-badge {
margin-left: 8px;
padding: 0 6px;
border-radius: 999px;
font-size: 10px;
font-weight: 650;
vertical-align: middle;
white-space: nowrap;
cursor: help;
}
.tls-badge.warn { background: var(--pending-bg); color: var(--pending-text); }
.tls-badge.danger { background: var(--down-bg); color: var(--down-text); }
.maint-badge { background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent); }
.card-head .tls-badge, .card-head .maint-badge { flex: none; }
.t-hb svg { display: block; width: 100%; max-width: 150px; height: 24px; } .t-hb svg { display: block; width: 100%; max-width: 150px; height: 24px; }
.t-uptime { white-space: nowrap; } .t-uptime { white-space: nowrap; }
/* 可用率条随视口伸缩;极窄时只留百分比,避免挤压其他列 */ /* 可用率条随视口伸缩;极窄时只留百分比,避免挤压其他列 */
+2 -1
View File
@@ -54,9 +54,10 @@
<span id="source-badge" class="footer-item">数据源:–</span> <span id="source-badge" class="footer-item">数据源:–</span>
<span id="demo-chip" class="footer-item demo-chip" hidden>演示数据</span> <span id="demo-chip" class="footer-item demo-chip" hidden>演示数据</span>
<span id="ur-chip" class="footer-item warn-chip" hidden>UptimeRobot 同步异常</span> <span id="ur-chip" class="footer-item warn-chip" hidden>UptimeRobot 同步异常</span>
<span id="notify-badge" class="footer-item" hidden>通知 –</span>
<span id="updated-at" class="footer-item">–</span> <span id="updated-at" class="footer-item">–</span>
<span id="countdown" class="footer-item">–</span> <span id="countdown" class="footer-item">–</span>
<span class="footer-item version">shiroko-monitor v0.9.1</span> <span class="footer-item version">shiroko-monitor v0.10.0</span>
</footer> </footer>
<script type="module" src="assets/app.js"></script> <script type="module" src="assets/app.js"></script>
+358 -280
View File
@@ -1,8 +1,8 @@
{ {
"prober": "shiroko-monitor-node/0.7.0", "prober": "shiroko-monitor-node/0.10.0",
"demo": false, "demo": false,
"generatedAt": "2026-10-13T14:36:59.637Z", "generatedAt": "2026-10-13T15:54:42.793Z",
"dataUpdatedAt": "2026-10-13T14:36:43.139Z", "dataUpdatedAt": "2026-10-13T15:53:59.134Z",
"updateIntervalSec": 300, "updateIntervalSec": 300,
"dataFilter": "uptimerobot", "dataFilter": "uptimerobot",
"stats": { "stats": {
@@ -10,12 +10,12 @@
"up": 5, "up": 5,
"down": 1, "down": 1,
"pending": 0, "pending": 0,
"avgLatency": 874, "avgLatency": 763,
"avgUptime90": 83.3, "avgUptime90": 83.3,
"memory": { "memory": {
"rssMB": 81.3, "rssMB": 67.4,
"heapUsedMB": 13.8, "heapUsedMB": 12.7,
"heapTotalMB": 15.7 "heapTotalMB": 14.2
} }
}, },
"sources": { "sources": {
@@ -23,30 +23,98 @@
"enabled": true, "enabled": true,
"ok": true, "ok": true,
"error": null, "error": null,
"lastSyncAt": "2026-10-13T14:36:16.338Z", "lastSyncAt": "2026-10-13T15:51:29.877Z",
"count": 6, "count": 6,
"syncing": false, "syncing": false,
"lastSyncMs": 1791902174992 "lastSyncMs": 1791906687760
} }
}, },
"notify": {
"enabled": false,
"channels": [],
"quietHours": null,
"failureThreshold": 2,
"lastResult": {
"at": "2026-10-13T15:53:51.149Z",
"suppressed": false,
"skipped": "notify-disabled",
"results": []
}
},
"events": [
{
"t": "2026-10-13T15:53:51.142Z",
"id": "cloudflare-site",
"name": "Cloudflare 官网",
"type": "up",
"detail": "已恢复",
"maintenance": false
},
{
"t": "2026-10-13T15:52:51.476Z",
"id": "cloudflare-site",
"name": "Cloudflare 官网",
"type": "down",
"detail": "ETIMEDOUT",
"maintenance": false
},
{
"t": "2026-10-13T15:32:38.735Z",
"id": "google-dns-tcp",
"name": "Google DNS 53 端口",
"type": "down",
"detail": "TCP 连接超时",
"maintenance": false
},
{
"t": "2026-10-13T15:32:38.735Z",
"id": "cloudflare-dns-tcp",
"name": "Cloudflare 443 端口",
"type": "down",
"detail": "TCP 连接超时",
"maintenance": false
},
{
"t": "2026-10-13T15:32:34.170Z",
"id": "badssl-expired",
"name": "BadSSL 过期证书(演示离线)",
"type": "down",
"detail": "CERT_HAS_EXPIRED",
"maintenance": false
},
{
"t": "2026-10-13T15:32:34.129Z",
"id": "badssl-expired",
"name": "BadSSL 过期证书(演示离线)",
"type": "tls",
"detail": "证书剩余 -4202 天(阈值 3 天)",
"maintenance": false
},
{
"t": "2026-10-13T15:32:34.129Z",
"id": "badssl-expired",
"name": "BadSSL 过期证书(演示离线)",
"type": "tls",
"detail": "证书剩余 -4202 天(阈值 7 天)",
"maintenance": false
},
{
"t": "2026-10-13T15:32:34.129Z",
"id": "badssl-expired",
"name": "BadSSL 过期证书(演示离线)",
"type": "tls",
"detail": "证书剩余 -4202 天(阈值 14 天)",
"maintenance": false
}
],
"monitors": { "monitors": {
"ur-792059439": { "ur-792059439": {
"status": "up", "status": "up",
"latency": 464, "latency": 233,
"code": null, "code": null,
"detail": "UptimeRobot:运行正常", "detail": "UptimeRobot:运行正常",
"lastCheck": "2026-10-13T14:34:00.000Z", "lastCheck": "2026-10-13T15:50:00.000Z",
"history": [ "history": [
{
"t": "2026-10-12T15:45:00.000Z",
"ok": true,
"ms": 392
},
{
"t": "2026-10-12T16:25:00.000Z",
"ok": true,
"ms": 288
},
{ {
"t": "2026-10-12T17:05:00.000Z", "t": "2026-10-12T17:05:00.000Z",
"ok": true, "ok": true,
@@ -203,20 +271,30 @@
"ms": 271 "ms": 271
}, },
{ {
"t": "2026-10-13T13:53:00.000Z", "t": "2026-10-13T13:55:00.000Z",
"ok": true, "ok": true,
"ms": 294 "ms": 307
}, },
{ {
"t": "2026-10-13T14:34:00.000Z", "t": "2026-10-13T14:34:00.000Z",
"ok": true, "ok": true,
"ms": 464 "ms": 463
},
{
"t": "2026-10-13T15:10:00.000Z",
"ok": true,
"ms": 260
},
{
"t": "2026-10-13T15:50:00.000Z",
"ok": true,
"ms": 233
} }
], ],
"daily": { "daily": {
"2026-10-13": { "2026-10-13": {
"total": 46, "total": 6,
"up": 46 "up": 6
} }
}, },
"meta": { "meta": {
@@ -243,186 +321,191 @@
}, },
"ur-795497022": { "ur-795497022": {
"status": "up", "status": "up",
"latency": 704, "latency": 772,
"code": null, "code": null,
"detail": "UptimeRobot:运行正常", "detail": "UptimeRobot:运行正常",
"lastCheck": "2026-10-13T14:33:00.000Z", "lastCheck": "2026-10-13T15:48:00.000Z",
"history": [ "history": [
{ {
"t": "2026-10-12T15:40:00.000Z", "t": "2026-10-12T16:35:00.000Z",
"ok": true,
"ms": 798
},
{
"t": "2026-10-12T16:20:00.000Z",
"ok": true,
"ms": 685
},
{
"t": "2026-10-12T17:00:00.000Z",
"ok": true, "ok": true,
"ms": 700 "ms": 700
}, },
{ {
"t": "2026-10-12T17:40:00.000Z", "t": "2026-10-12T17:15:00.000Z",
"ok": true, "ok": true,
"ms": 694 "ms": 689
}, },
{ {
"t": "2026-10-12T18:20:00.000Z", "t": "2026-10-12T17:55:00.000Z",
"ok": true, "ok": true,
"ms": 703 "ms": 725
}, },
{ {
"t": "2026-10-12T19:00:00.000Z", "t": "2026-10-12T18:35:00.000Z",
"ok": true, "ok": true,
"ms": 729 "ms": 744
}, },
{ {
"t": "2026-10-12T19:40:00.000Z", "t": "2026-10-12T19:15:00.000Z",
"ok": true, "ok": true,
"ms": 713 "ms": 723
}, },
{ {
"t": "2026-10-12T20:20:00.000Z", "t": "2026-10-12T19:55:00.000Z",
"ok": true, "ok": true,
"ms": 784 "ms": 698
}, },
{ {
"t": "2026-10-12T21:00:00.000Z", "t": "2026-10-12T20:35:00.000Z",
"ok": true, "ok": true,
"ms": 628 "ms": 768
}, },
{ {
"t": "2026-10-12T21:40:00.000Z", "t": "2026-10-12T21:15:00.000Z",
"ok": true, "ok": true,
"ms": 629 "ms": 616
}, },
{ {
"t": "2026-10-12T22:20:00.000Z", "t": "2026-10-12T21:55:00.000Z",
"ok": true, "ok": true,
"ms": 751 "ms": 626
}, },
{ {
"t": "2026-10-12T23:00:00.000Z", "t": "2026-10-12T22:35:00.000Z",
"ok": true,
"ms": 674
},
{
"t": "2026-10-12T23:15:00.000Z",
"ok": true, "ok": true,
"ms": 624 "ms": 624
}, },
{ {
"t": "2026-10-12T23:40:00.000Z", "t": "2026-10-12T23:55:00.000Z",
"ok": true, "ok": true,
"ms": 715 "ms": 640
}, },
{ {
"t": "2026-10-13T00:20:00.000Z", "t": "2026-10-13T00:35:00.000Z",
"ok": true, "ok": true,
"ms": 683 "ms": 673
}, },
{ {
"t": "2026-10-13T01:00:00.000Z", "t": "2026-10-13T01:15:00.000Z",
"ok": true,
"ms": 643
},
{
"t": "2026-10-13T01:40:00.000Z",
"ok": true,
"ms": 641
},
{
"t": "2026-10-13T02:20:00.000Z",
"ok": true, "ok": true,
"ms": 609 "ms": 609
}, },
{ {
"t": "2026-10-13T03:00:00.000Z", "t": "2026-10-13T01:55:00.000Z",
"ok": true, "ok": true,
"ms": 606 "ms": 759
}, },
{ {
"t": "2026-10-13T03:40:00.000Z", "t": "2026-10-13T02:35:00.000Z",
"ok": true, "ok": true,
"ms": 720 "ms": 581
}, },
{ {
"t": "2026-10-13T04:20:00.000Z", "t": "2026-10-13T03:15:00.000Z",
"ok": true, "ok": true,
"ms": 711 "ms": 666
}, },
{ {
"t": "2026-10-13T05:00:00.000Z", "t": "2026-10-13T03:55:00.000Z",
"ok": true, "ok": true,
"ms": 598 "ms": 662
}, },
{ {
"t": "2026-10-13T05:40:00.000Z", "t": "2026-10-13T04:35:00.000Z",
"ok": true, "ok": true,
"ms": 729 "ms": 685
}, },
{ {
"t": "2026-10-13T06:20:00.000Z", "t": "2026-10-13T05:15:00.000Z",
"ok": true, "ok": true,
"ms": 676 "ms": 647
}, },
{ {
"t": "2026-10-13T07:00:00.000Z", "t": "2026-10-13T05:55:00.000Z",
"ok": true, "ok": true,
"ms": 691 "ms": 588
}, },
{ {
"t": "2026-10-13T07:40:00.000Z", "t": "2026-10-13T06:40:00.000Z",
"ok": true, "ok": true,
"ms": 696 "ms": 625
}, },
{ {
"t": "2026-10-13T08:20:00.000Z", "t": "2026-10-13T07:20:00.000Z",
"ok": true, "ok": true,
"ms": 677 "ms": 673
}, },
{ {
"t": "2026-10-13T09:00:00.000Z", "t": "2026-10-13T08:00:00.000Z",
"ok": true, "ok": true,
"ms": 969 "ms": 758
}, },
{ {
"t": "2026-10-13T09:40:00.000Z", "t": "2026-10-13T08:40:00.000Z",
"ok": true, "ok": true,
"ms": 669 "ms": 826
}, },
{ {
"t": "2026-10-13T10:20:00.000Z", "t": "2026-10-13T09:20:00.000Z",
"ok": true, "ok": true,
"ms": 840 "ms": 749
}, },
{ {
"t": "2026-10-13T11:00:00.000Z", "t": "2026-10-13T10:00:00.000Z",
"ok": true, "ok": true,
"ms": 735 "ms": 791
}, },
{ {
"t": "2026-10-13T11:40:00.000Z", "t": "2026-10-13T10:40:00.000Z",
"ok": true, "ok": true,
"ms": 693 "ms": 678
}, },
{ {
"t": "2026-10-13T12:20:00.000Z", "t": "2026-10-13T11:20:00.000Z",
"ok": true, "ok": true,
"ms": 793 "ms": 740
}, },
{ {
"t": "2026-10-13T13:56:00.000Z", "t": "2026-10-13T12:00:00.000Z",
"ok": true, "ok": true,
"ms": 1056 "ms": 828
}, },
{ {
"t": "2026-10-13T14:33:00.000Z", "t": "2026-10-13T12:40:00.000Z",
"ok": true, "ok": true,
"ms": 704 "ms": 722
},
{
"t": "2026-10-13T13:20:00.000Z",
"ok": true,
"ms": 849
},
{
"t": "2026-10-13T14:43:00.000Z",
"ok": true,
"ms": 845
},
{
"t": "2026-10-13T15:15:00.000Z",
"ok": true,
"ms": 857
},
{
"t": "2026-10-13T15:48:00.000Z",
"ok": true,
"ms": 772
} }
], ],
"daily": { "daily": {
"2026-10-13": { "2026-10-13": {
"total": 46, "total": 6,
"up": 46 "up": 6
} }
}, },
"meta": { "meta": {
@@ -449,21 +532,11 @@
}, },
"ur-795556828": { "ur-795556828": {
"status": "up", "status": "up",
"latency": 338, "latency": 230,
"code": null, "code": null,
"detail": "UptimeRobot:运行正常", "detail": "UptimeRobot:运行正常",
"lastCheck": "2026-10-13T14:34:00.000Z", "lastCheck": "2026-10-13T15:50:00.000Z",
"history": [ "history": [
{
"t": "2026-10-12T15:35:00.000Z",
"ok": true,
"ms": 275
},
{
"t": "2026-10-12T16:15:00.000Z",
"ok": true,
"ms": 450
},
{ {
"t": "2026-10-12T16:55:00.000Z", "t": "2026-10-12T16:55:00.000Z",
"ok": true, "ok": true,
@@ -620,20 +693,30 @@
"ms": 413 "ms": 413
}, },
{ {
"t": "2026-10-13T13:54:00.000Z", "t": "2026-10-13T13:55:00.000Z",
"ok": true, "ok": true,
"ms": 187 "ms": 283
}, },
{ {
"t": "2026-10-13T14:34:00.000Z", "t": "2026-10-13T14:34:00.000Z",
"ok": true, "ok": true,
"ms": 338 "ms": 333
},
{
"t": "2026-10-13T15:10:00.000Z",
"ok": true,
"ms": 510
},
{
"t": "2026-10-13T15:50:00.000Z",
"ok": true,
"ms": 230
} }
], ],
"daily": { "daily": {
"2026-10-13": { "2026-10-13": {
"total": 46, "total": 6,
"up": 46 "up": 6
} }
}, },
"meta": { "meta": {
@@ -660,21 +743,11 @@
}, },
"ur-792059414": { "ur-792059414": {
"status": "up", "status": "up",
"latency": 2424, "latency": 2238,
"code": null, "code": null,
"detail": "UptimeRobot:运行正常", "detail": "UptimeRobot:运行正常",
"lastCheck": "2026-10-13T14:34:00.000Z", "lastCheck": "2026-10-13T15:48:00.000Z",
"history": [ "history": [
{
"t": "2026-10-12T15:40:00.000Z",
"ok": true,
"ms": 2901
},
{
"t": "2026-10-12T16:25:00.000Z",
"ok": true,
"ms": 2402
},
{ {
"t": "2026-10-12T17:10:00.000Z", "t": "2026-10-12T17:10:00.000Z",
"ok": true, "ok": true,
@@ -796,45 +869,50 @@
"ms": 1597 "ms": 1597
}, },
{ {
"t": "2026-10-13T10:35:00.000Z", "t": "2026-10-13T10:40:00.000Z",
"ok": true, "ok": true,
"ms": 3015 "ms": 2816
}, },
{ {
"t": "2026-10-13T11:15:00.000Z", "t": "2026-10-13T11:25:00.000Z",
"ok": true, "ok": true,
"ms": 1965 "ms": 1912
}, },
{ {
"t": "2026-10-13T11:55:00.000Z", "t": "2026-10-13T12:10:00.000Z",
"ok": true, "ok": true,
"ms": 2118 "ms": 2326
}, },
{ {
"t": "2026-10-13T12:35:00.000Z", "t": "2026-10-13T12:55:00.000Z",
"ok": true, "ok": true,
"ms": 2557 "ms": 2938
}, },
{ {
"t": "2026-10-13T13:15:00.000Z", "t": "2026-10-13T13:40:00.000Z",
"ok": true, "ok": true,
"ms": 1671 "ms": 3146
}, },
{ {
"t": "2026-10-13T13:55:00.000Z", "t": "2026-10-13T14:25:00.000Z",
"ok": true, "ok": true,
"ms": 1908 "ms": 2125
}, },
{ {
"t": "2026-10-13T14:34:00.000Z", "t": "2026-10-13T15:08:00.000Z",
"ok": true, "ok": true,
"ms": 2424 "ms": 2892
},
{
"t": "2026-10-13T15:48:00.000Z",
"ok": true,
"ms": 2238
} }
], ],
"daily": { "daily": {
"2026-10-13": { "2026-10-13": {
"total": 46, "total": 6,
"up": 46 "up": 6
} }
}, },
"meta": { "meta": {
@@ -861,191 +939,191 @@
}, },
"ur-798788113": { "ur-798788113": {
"status": "up", "status": "up",
"latency": 438, "latency": 340,
"code": null, "code": null,
"detail": "UptimeRobot:运行正常", "detail": "UptimeRobot:运行正常",
"lastCheck": "2026-10-13T14:35:00.000Z", "lastCheck": "2026-10-13T15:46:00.000Z",
"history": [ "history": [
{ {
"t": "2026-10-12T15:45:00.000Z", "t": "2026-10-12T16:50:00.000Z",
"ok": true, "ok": true,
"ms": 462 "ms": 463
}, },
{ {
"t": "2026-10-12T16:25:00.000Z", "t": "2026-10-12T17:30:00.000Z",
"ok": true, "ok": true,
"ms": 288 "ms": 320
}, },
{ {
"t": "2026-10-12T17:05:00.000Z", "t": "2026-10-12T18:10:00.000Z",
"ok": true, "ok": true,
"ms": 409 "ms": 404
}, },
{ {
"t": "2026-10-12T17:50:00.000Z", "t": "2026-10-12T18:50:00.000Z",
"ok": true,
"ms": 312
},
{
"t": "2026-10-12T18:30:00.000Z",
"ok": true,
"ms": 472
},
{
"t": "2026-10-12T19:10:00.000Z",
"ok": true,
"ms": 421
},
{
"t": "2026-10-12T19:50:00.000Z",
"ok": true,
"ms": 332
},
{
"t": "2026-10-12T20:30:00.000Z",
"ok": true,
"ms": 310
},
{
"t": "2026-10-12T21:10:00.000Z",
"ok": true,
"ms": 436
},
{
"t": "2026-10-12T21:50:00.000Z",
"ok": true,
"ms": 338
},
{
"t": "2026-10-12T22:30:00.000Z",
"ok": true,
"ms": 423
},
{
"t": "2026-10-12T23:10:00.000Z",
"ok": true,
"ms": 247
},
{
"t": "2026-10-12T23:50:00.000Z",
"ok": true,
"ms": 296
},
{
"t": "2026-10-13T00:30:00.000Z",
"ok": true,
"ms": 239
},
{
"t": "2026-10-13T01:10:00.000Z",
"ok": true,
"ms": 393
},
{
"t": "2026-10-13T01:50:00.000Z",
"ok": true,
"ms": 346
},
{
"t": "2026-10-13T02:30:00.000Z",
"ok": true,
"ms": 441
},
{
"t": "2026-10-13T03:10:00.000Z",
"ok": true,
"ms": 443
},
{
"t": "2026-10-13T03:50:00.000Z",
"ok": true,
"ms": 382
},
{
"t": "2026-10-13T04:30:00.000Z",
"ok": true,
"ms": 355
},
{
"t": "2026-10-13T05:10:00.000Z",
"ok": true,
"ms": 414
},
{
"t": "2026-10-13T05:50:00.000Z",
"ok": true, "ok": true,
"ms": 343 "ms": 343
}, },
{ {
"t": "2026-10-13T06:30:00.000Z", "t": "2026-10-12T19:30:00.000Z",
"ok": true, "ok": true,
"ms": 434 "ms": 476
}, },
{ {
"t": "2026-10-13T07:10:00.000Z", "t": "2026-10-12T20:10:00.000Z",
"ok": true, "ok": true,
"ms": 444 "ms": 285
}, },
{ {
"t": "2026-10-13T07:50:00.000Z", "t": "2026-10-12T20:50:00.000Z",
"ok": true, "ok": true,
"ms": 281 "ms": 449
}, },
{ {
"t": "2026-10-13T08:30:00.000Z", "t": "2026-10-12T21:30:00.000Z",
"ok": true, "ok": true,
"ms": 453 "ms": 332
}, },
{ {
"t": "2026-10-13T09:15:00.000Z", "t": "2026-10-12T22:10:00.000Z",
"ok": true, "ok": true,
"ms": 333 "ms": 450
}, },
{ {
"t": "2026-10-13T09:55:00.000Z", "t": "2026-10-12T22:50:00.000Z",
"ok": true, "ok": true,
"ms": 602 "ms": 231
}, },
{ {
"t": "2026-10-13T10:35:00.000Z", "t": "2026-10-12T23:30:00.000Z",
"ok": true, "ok": true,
"ms": 519 "ms": 249
}, },
{ {
"t": "2026-10-13T11:15:00.000Z", "t": "2026-10-13T00:10:00.000Z",
"ok": true, "ok": true,
"ms": 325 "ms": 286
}, },
{ {
"t": "2026-10-13T11:55:00.000Z", "t": "2026-10-13T00:50:00.000Z",
"ok": true, "ok": true,
"ms": 933 "ms": 223
}, },
{ {
"t": "2026-10-13T12:35:00.000Z", "t": "2026-10-13T01:30:00.000Z",
"ok": true, "ok": true,
"ms": 424 "ms": 386
}, },
{ {
"t": "2026-10-13T13:15:00.000Z", "t": "2026-10-13T02:10:00.000Z",
"ok": true, "ok": true,
"ms": 460 "ms": 511
}, },
{ {
"t": "2026-10-13T13:55:00.000Z", "t": "2026-10-13T02:50:00.000Z",
"ok": true, "ok": true,
"ms": 328 "ms": 292
}, },
{ {
"t": "2026-10-13T14:35:00.000Z", "t": "2026-10-13T03:30:00.000Z",
"ok": true, "ok": true,
"ms": 438 "ms": 435
},
{
"t": "2026-10-13T04:15:00.000Z",
"ok": true,
"ms": 400
},
{
"t": "2026-10-13T04:55:00.000Z",
"ok": true,
"ms": 462
},
{
"t": "2026-10-13T05:35:00.000Z",
"ok": true,
"ms": 377
},
{
"t": "2026-10-13T06:15:00.000Z",
"ok": true,
"ms": 396
},
{
"t": "2026-10-13T06:55:00.000Z",
"ok": true,
"ms": 310
},
{
"t": "2026-10-13T07:35:00.000Z",
"ok": true,
"ms": 336
},
{
"t": "2026-10-13T08:15:00.000Z",
"ok": true,
"ms": 379
},
{
"t": "2026-10-13T08:55:00.000Z",
"ok": true,
"ms": 476
},
{
"t": "2026-10-13T09:35:00.000Z",
"ok": true,
"ms": 605
},
{
"t": "2026-10-13T10:15:00.000Z",
"ok": true,
"ms": 456
},
{
"t": "2026-10-13T10:55:00.000Z",
"ok": true,
"ms": 420
},
{
"t": "2026-10-13T11:35:00.000Z",
"ok": true,
"ms": 878
},
{
"t": "2026-10-13T12:15:00.000Z",
"ok": true,
"ms": 530
},
{
"t": "2026-10-13T12:55:00.000Z",
"ok": true,
"ms": 517
},
{
"t": "2026-10-13T13:35:00.000Z",
"ok": true,
"ms": 491
},
{
"t": "2026-10-13T14:20:00.000Z",
"ok": true,
"ms": 429
},
{
"t": "2026-10-13T15:06:00.000Z",
"ok": true,
"ms": 443
},
{
"t": "2026-10-13T15:46:00.000Z",
"ok": true,
"ms": 340
} }
], ],
"daily": { "daily": {
"2026-10-13": { "2026-10-13": {
"total": 46, "total": 6,
"up": 46 "up": 6
} }
}, },
"meta": { "meta": {
@@ -1085,7 +1163,7 @@
], ],
"daily": { "daily": {
"2026-10-13": { "2026-10-13": {
"total": 46, "total": 6,
"up": 0 "up": 0
} }
}, },
+371
View File
@@ -0,0 +1,371 @@
/**
* shiroko-monitor 通知与证书验证
*
* 覆盖:
* A. 邮件通道(假 SMTP 服务器):完整握手、AUTH LOGIN、多收件人、UTF-8 主题与正文、
* 5xx 报错路径、不支持 STARTTLS 时默认拒绝明文
* B. MeoW 通道(假 HTTP 服务器):成功(status=200)、404 昵称未注册、429 限流、
* 「HTTP 200 但业务 status≠200」、非 JSON 响应
* C. 静默时段(含跨零点)与 MeoW 本地限流器
* D. 集成:真跑探针 --once,验证去抖(连续失败 N 次才通知)、恢复通知、
* 事件时间线落盘、维护窗口抑制告警但仍记录事件
* E. 证书:tlsTargetOf 解析 + 真实握手(无网络则跳过,不算失败)
*
* 用法:node scripts/verify-notify.cjs
*/
const fs = require('node:fs');
const net = require('node:net');
const http = require('node:http');
const os = require('node:os');
const path = require('node:path');
const { spawn } = require('node:child_process');
const ROOT = path.join(__dirname, '..');
const PROBER = path.join(ROOT, 'prober', 'node', 'index.js');
const LIB = path.join(ROOT, 'prober', 'node', 'lib', 'notify.js');
const TLS_LIB = path.join(ROOT, 'prober', 'node', 'lib', 'tls-probe.js');
const SMTP_PORT = 8895;
const MEOW_PORT = 8896;
const results = [];
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function check(name, pass, detail) {
results.push({ name, pass });
console.log(`${pass ? ' ✓' : ' ✗'} ${name}${detail ? ` — ${detail}` : ''}`);
}
/* ---------- 假 SMTP 服务器 ---------- */
function startFakeSmtp() {
const received = [];
let failAt = null; // 'auth' | 'mail' 注入 5xx
const server = net.createServer((socket) => {
let buf = '';
let stage = 'greeting'; // greeting → ehlo → auth-user → auth-pass → ready → data
const send = (line) => socket.write(`${line}\r\n`);
send('220 fake-smtp ready');
socket.on('data', (chunk) => {
buf += chunk.toString('utf8');
if (!buf.endsWith('\r\n')) return;
const line = buf.trim();
buf = '';
const cmd = line.split(/\s+/)[0].toUpperCase();
// 不实现 TLS:EHLO 只通告 AUTH LOGIN(客户端会按 allowInsecure 决定是否明文继续)
if (stage === 'greeting' && cmd === 'EHLO') {
// SMTP 多行响应:最后一行必须是「250 + 空格」才算终止(客户端按此判定)
send('250-fake-smtp');
send('250-AUTH LOGIN PLAIN');
send('250 OK');
stage = 'ehlo';
} else if (cmd === 'AUTH' && line.toUpperCase().startsWith('AUTH LOGIN')) {
if (failAt === 'auth') return send('535 认证失败');
send('334 VXNlcm5hbWU6');
stage = 'auth-user';
} else if (stage === 'auth-user') {
send('334 UGFzc3dvcmQ6');
stage = 'auth-pass';
} else if (stage === 'auth-pass') {
send('235 认证成功');
stage = 'ready';
} else if (cmd === 'MAIL') {
if (failAt === 'mail') return send('550 发件人被拒');
received.push({ mailFrom: /<([^>]*)>/.exec(line)?.[1] ?? '' });
send('250 ok');
} else if (cmd === 'RCPT') {
const addr = /<([^>]*)>/.exec(line)?.[1] ?? '';
if (!received.length) received.push({});
received.at(-1).rcpt = [...(received.at(-1).rcpt ?? []), addr];
send('250 ok');
} else if (cmd === 'DATA') {
send('354 end with <CRLF>.<CRLF>');
stage = 'data';
} else if (stage === 'data') {
received.at(-1).raw = line;
stage = 'done';
send('250 queued');
} else if (cmd === 'QUIT') {
send('221 bye');
socket.end();
} else {
send('250 ok');
}
});
socket.on('error', () => {});
});
return new Promise((resolve) => server.listen(SMTP_PORT, '127.0.0.1', () => resolve({
received,
failAt: (v) => { failAt = v; },
close: () => new Promise((r) => server.close(r)),
})));
}
/* ---------- 假 MeoW 服务器 ---------- */
function startFakeMeoW() {
const received = [];
const server = http.createServer((req, res) => {
const nickname = decodeURIComponent(req.url.split('?')[0].replace(/^\//, ''));
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
received.push({ nickname, url: req.url, body });
const json = (status, message) => {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ status, message }));
};
// 注意:MeoW 的 HTTP 状态恒为 200,业务结果只在 body 里
if (nickname === 'ok') return json(200, '推送成功');
if (nickname === 'limited') return json(429, '请求过于频繁');
if (nickname === 'missing') return json(404, '昵称未注册');
res.writeHead(200, { 'content-type': 'text/html' });
res.end('<html>not json</html>');
});
});
return new Promise((resolve) => server.listen(MEOW_PORT, '127.0.0.1', () => resolve({
received,
close: () => new Promise((r) => server.close(r)),
})));
}
/* ---------- 探针进程 ---------- */
function runOnce(args) {
return new Promise((resolve) => {
const child = spawn(process.execPath, [PROBER, ...args], { stdio: ['ignore', 'pipe', 'pipe'] });
let out = '';
child.stdout.on('data', (d) => (out += d));
child.stderr.on('data', (d) => (out += d));
child.on('exit', (code) => resolve({ code, out }));
});
}
function writeConfig(dir, overrides = {}) {
const cfg = {
settings: {
title: 'notify-test',
dataFilter: 'config',
refreshInterval: 60,
notify: {
enabled: true,
failureThreshold: 2,
recoveryNotify: true,
channels: {
email: {
enabled: true, host: '127.0.0.1', port: SMTP_PORT, secure: false, allowInsecure: true,
user: '[email protected]', pass: 'secret', from: '[email protected]',
to: ['[email protected]', '[email protected]'],
},
meow: { enabled: true, nickname: 'ok', baseUrl: `http://127.0.0.1:${MEOW_PORT}` },
},
},
},
monitors: [
// 拒绝连接 → 离线(127.0.0.1:1 一定连不上)
{ id: 'down-tcp', name: '必然离线的 TCP', group: '测试', type: 'tcp', host: '127.0.0.1', port: 1, interval: 60, timeout: 3 },
// data: URL 一定成功 → 在线
{ id: 'up-http', name: '必然在线的 HTTP', group: '测试', type: 'http', url: 'data:text/plain,ok', expectStatus: [200], interval: 60, timeout: 5 },
],
...overrides,
};
const file = path.join(dir, 'monitors.json');
fs.writeFileSync(file, `${JSON.stringify(cfg, null, 2)}\n`);
return { file, cfg };
}
async function main() {
const { sendEmail, sendMeoW, sendTestNotifications, notifyAll, parseQuietHours, inQuietHours, createMeoWLimiter } = await import(`file://${LIB.replace(/\\/g, '/')}`);
const { probeCertificate, tlsTargetOf } = await import(`file://${TLS_LIB.replace(/\\/g, '/')}`);
const smtp = await startFakeSmtp();
const meow = await startFakeMeoW();
try {
/* ---------- A. 邮件通道 ---------- */
console.log('\n[A] 邮件通道(假 SMTP)');
const emailCfg = {
enabled: true, host: '127.0.0.1', port: SMTP_PORT, secure: false, allowInsecure: true,
user: '[email protected]', pass: 's3cret!', from: '[email protected]', to: ['[email protected]', '[email protected]'],
};
await sendEmail(emailCfg, { subject: '【故障】测试目标 连接失败', text: '第一行\n第二行:中文内容' });
const mail = smtp.received.at(-1);
check('邮件已发出', Boolean(mail?.raw), '假服务器收到 DATA');
check('发件人与收件人正确', mail?.mailFrom === '[email protected]' && mail?.rcpt?.join(',') === '[email protected],[email protected]', `${mail?.mailFrom} → ${mail?.rcpt?.join(',')}`);
const headers = (mail?.raw ?? '').split('\r\n\r\n')[0] ?? '';
const bodyB64 = (mail?.raw ?? '').split('\r\n\r\n')[1] ?? '';
check('中文主题按 RFC2047 编码', /^Subject: =\?UTF-8\?B\?/m.test(headers), headers.split('\r\n').find((l) => l.startsWith('Subject:'))?.slice(0, 46) + '…');
check('正文 base64 且可解码出中文', Buffer.from(bodyB64.replace(/\r\n/g, ''), 'base64').toString('utf8').includes('第二行:中文内容'), '');
check('声明 UTF-8 字符集', /Content-Type: text\/plain; charset=UTF-8/.test(headers), '');
smtp.failAt('mail');
let err = null;
try { await sendEmail(emailCfg, { subject: 'x', text: 'y' }); } catch (e) { err = e; }
check('SMTP 5xx 被识别为失败', /发件人被拒/.test(err?.message ?? ''), err?.message ?? '未抛错');
smtp.failAt(null); // 复位注入的故障,避免污染后续用例
let err2 = null;
try { await sendEmail({ ...emailCfg, host: '127.0.0.1', port: 8999 }, { subject: 'x', text: 'y' }); } catch (e) { err2 = e; }
check('连不上 SMTP 时报错可读', /ECONNREFUSED|超时|连接/.test(err2?.message ?? ''), err2?.message?.slice(0, 60) ?? '未抛错');
let err3 = null;
try { await sendEmail({ ...emailCfg, allowInsecure: false }, { subject: 'x', text: 'y' }); } catch (e) { err3 = e; }
check('服务器无 STARTTLS 时默认拒绝明文发信', /STARTTLS/.test(err3?.message ?? ''), err3?.message?.slice(0, 70) ?? '未抛错');
/* ---------- B. MeoW 通道 ---------- */
console.log('\n[B] MeoW 通道(假 HTTP)');
const meowCfg = { nickname: 'ok', baseUrl: `http://127.0.0.1:${MEOW_PORT}` };
const r1 = await sendMeoW(meowCfg, { title: '标题', text: '正文' });
check('status=200 判定为成功', r1.ok === true, r1.detail ?? '');
const req1 = meow.received.at(-1);
check('昵称走路径、msgType 不作为 body', req1.nickname === 'ok' && !req1.body.includes('msgType'), `POST /${req1.nickname}${req1.url.includes('?') ? req1.url.slice(req1.url.indexOf('?')) : ''}`);
check('JSON body 含 title 与 msg', (() => { const b = JSON.parse(req1.body); return b.title === '标题' && b.msg === '正文'; })(), '');
for (const [nick, label] of [['limited', '429 限流'], ['missing', '404 昵称未注册']]) {
let e = null;
try { await sendMeoW({ ...meowCfg, nickname: nick }, { title: 't', text: 'm' }); } catch (err3) { e = err3; }
check(`${label} 被识别为失败`, Boolean(e), e?.message?.slice(0, 70) ?? '未抛错');
}
let e4 = null;
try { await sendMeoW({ ...meowCfg, nickname: 'broken' }, { title: 't', text: 'm' }); } catch (err4) { e4 = err4; }
check('非 JSON 响应被识别为失败', /不是 JSON/.test(e4?.message ?? ''), e4?.message?.slice(0, 50) ?? '');
let e5 = null;
try { await sendMeoW({ ...meowCfg, nickname: 'a/b' }, { title: 't', text: 'm' }); } catch (err5) { e5 = err5; }
check('昵称含斜杠被拒绝', /斜杠/.test(e5?.message ?? ''), e5?.message ?? '');
/* ---------- C. 静默时段与限流器 ---------- */
console.log('\n[C] 静默时段与限流器');
check('静默时段解析', parseQuietHours('23:00-07:00').from === 23 * 60, '');
check('跨零点判定(23:30 在静默中)', inQuietHours('23:00-07:00', new Date('2026-10-13T23:30:00')) === true, '');
check('跨零点判定(07:30 不在静默中)', inQuietHours('23:00-07:00', new Date('2026-10-13T07:30:00')) === false, '');
check('同时段判定(09:00-17:00 内)', inQuietHours('09:00-17:00', new Date('2026-10-13T12:00:00')) === true, '');
const limiter = createMeoWLimiter(() => 0);
check('限流器首次放行', limiter.allow() === true, '');
limiter.mark();
check('限流器 3 秒内拦截', limiter.allow() === false, '');
const quiet = await notifyAll(
{ enabled: true, quietHours: '23:00-07:00', channels: { meow: { enabled: true, nickname: 'ok', baseUrl: `http://127.0.0.1:${MEOW_PORT}` } } },
{ subject: '静默测试', text: '不应发送' },
{ logger: console, fetchImpl: fetch },
);
const before = meow.received.length;
const now = new Date();
const quietCfg = { enabled: true, quietHours: `${String(now.getHours()).padStart(2, '0')}:00-${String(now.getHours()).padStart(2, '0')}:59`, channels: { meow: { enabled: true, nickname: 'ok', baseUrl: `http://127.0.0.1:${MEOW_PORT}` } } };
const q = await notifyAll(quietCfg, { subject: '静默测试', text: '不应发送' }, { logger: console });
check('静默时段内不发送且标记 suppressed', q.suppressed === true && meow.received.length === before, `received ${before} → ${meow.received.length}`);
/* ---------- D. 集成:真探针 ---------- */
console.log('\n[D] 集成(探针 --once × 多轮)');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'shiroko-notify-'));
fs.cpSync(path.join(ROOT, 'public'), tmp, { recursive: true });
const { file } = writeConfig(tmp);
const dataDir = path.join(tmp, 'data');
const outFile = path.join(tmp, 'status.json');
const common = [`--config=${file}`, `--local-config=${file}`, `--out=${outFile}`, `--data-dir=${dataDir}`];
const beforeMail = smtp.received.length;
const beforeMeow = meow.received.length;
const run1 = await runOnce(['--once', ...common]);
check('第一轮探测成功', run1.code === 0, run1.out.split('\n').find((l) => l.includes('[once]')) ?? '');
const mailAfter1 = smtp.received.length - beforeMail;
const meowAfter1 = meow.received.length - beforeMeow;
check('离线目标连续 2 次失败才告警:第一轮不通知', mailAfter1 === 0 && meowAfter1 === 0, `邮件 ${mailAfter1} / MeoW ${meowAfter1}`);
// 事件文件只在有事件时才落盘,因此要容忍不存在
const readEvents = (dir) => {
const f = path.join(dir, 'events.json');
return fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, 'utf8')) : [];
};
const events1 = readEvents(dataDir);
check('第一轮未记录事件(去抖中)', events1.length === 0, `events=${events1.length}`);
await runOnce(['--once', ...common]);
const mailAfter2 = smtp.received.length - beforeMail;
const meowAfter2 = meow.received.length - beforeMeow;
check('第二轮达到阈值:邮件与 MeoW 各 1 条', mailAfter2 === 1 && meowAfter2 === 1, `邮件 ${mailAfter2} / MeoW ${meowAfter2}`);
const decodeMail = (raw) => {
const [head, body = ''] = raw.split('\r\n\r\n');
const subject = /^Subject: =\?UTF-8\?B\?(.*)\?=/m.exec(head);
return {
subject: subject ? Buffer.from(subject[1], 'base64').toString('utf8') : (/^Subject: (.*)$/m.exec(head)?.[1] ?? ''),
body: Buffer.from(body.replace(/\r\n/g, ''), 'base64').toString('utf8'),
};
};
const downMail = smtp.received.at(-1);
const downDecoded = decodeMail(downMail.raw);
check('告警邮件含目标名称与详情', /必然离线的 TCP/.test(downDecoded.body) && /【故障】/.test(downDecoded.subject), `主题「${downDecoded.subject}」正文 ${downDecoded.body.length} 字符`);
const events2 = readEvents(dataDir);
check('事件时间线记录 down', events2.some((e) => e.type === 'down' && e.id === 'down-tcp'), JSON.stringify(events2.map((e) => e.type)));
await runOnce(['--once', ...common]);
const mailAfter3 = smtp.received.length - beforeMail;
check('第三轮不重复告警(去抖状态已持久化)', mailAfter3 === 1, `邮件累计 ${mailAfter3}`);
// 恢复:把离线目标换成 data: URL,下一轮应发「恢复」
const upCfg = JSON.parse(fs.readFileSync(file, 'utf8'));
upCfg.monitors = upCfg.monitors.map((m) => (m.id === 'down-tcp' ? { ...m, type: 'http', url: 'data:text/plain,ok', expectStatus: [200] } : m));
delete upCfg.monitors.find((m) => m.id === 'down-tcp').host;
delete upCfg.monitors.find((m) => m.id === 'down-tcp').port;
fs.writeFileSync(file, `${JSON.stringify(upCfg, null, 2)}\n`);
await runOnce(['--once', ...common]);
const recovery = smtp.received.at(-1);
const recoveryDecoded = decodeMail(recovery.raw);
check('恢复时发送「恢复」通知', /【恢复】/.test(recoveryDecoded.subject) && /恢复时间/.test(recoveryDecoded.body), `主题「${recoveryDecoded.subject}」`);
const events3 = readEvents(dataDir);
check('事件时间线记录 up(恢复)', events3.some((e) => e.type === 'up' && e.id === 'down-tcp'), JSON.stringify(events3.map((e) => e.type)));
/* ---------- D2. 维护窗口 ---------- */
console.log('\n[D2] 维护窗口抑制告警');
const tmp2 = fs.mkdtempSync(path.join(os.tmpdir(), 'shiroko-maint-'));
fs.cpSync(path.join(ROOT, 'public'), tmp2, { recursive: true });
const { file: file2 } = writeConfig(tmp2);
const cfg2 = JSON.parse(fs.readFileSync(file2, 'utf8'));
cfg2.settings.maintenance = [{ from: new Date(Date.now() - 3600_000).toISOString(), to: new Date(Date.now() + 3600_000).toISOString(), note: '计划维护' }];
fs.writeFileSync(file2, `${JSON.stringify(cfg2, null, 2)}\n`);
const dataDir2 = path.join(tmp2, 'data');
const outFile2 = path.join(tmp2, 'status.json');
const beforeMail2 = smtp.received.length;
await runOnce(['--once', `--config=${file2}`, `--local-config=${file2}`, `--out=${outFile2}`, `--data-dir=${dataDir2}`]);
await runOnce(['--once', `--config=${file2}`, `--local-config=${file2}`, `--out=${outFile2}`, `--data-dir=${dataDir2}`]);
check('维护窗口内不发告警', smtp.received.length === beforeMail2, `新增 ${smtp.received.length - beforeMail2} 封`);
const events4 = readEvents(dataDir2);
check('维护窗口内仍记录事件(标记 maintenance)', events4.some((e) => e.type === 'down' && e.maintenance === true), JSON.stringify(events4.map((e) => `${e.type}:${e.maintenance}`)));
const snap2 = JSON.parse(fs.readFileSync(outFile2, 'utf8'));
check('快照中目标带维护标记', snap2.monitors['down-tcp'].maintenance?.note === '计划维护', '');
/* ---------- E. 证书 ---------- */
console.log('\n[E] 证书');
check('tlsTargetOf 识别 https 目标', tlsTargetOf({ type: 'http', url: 'https://www.example.com:8443/x' }).port === 8443, '');
check('tlsTargetOf 忽略 http 目标', tlsTargetOf({ type: 'http', url: 'http://www.example.com' }) === null, '');
check('tlsTargetOf 尊重 tls:false', tlsTargetOf({ type: 'http', url: 'https://www.example.com', tls: false }) === null, '');
try {
const cert = await probeCertificate({ host: 'www.example.com', timeoutMs: 8000 });
if (cert.error) {
console.log(` - 真实握手失败(可能无外网):${cert.error} → 跳过`);
} else {
check('真实证书可取到剩余天数', Number.isFinite(cert.daysLeft) && cert.daysLeft > -1, `剩余 ${cert.daysLeft} 天,签发者 ${cert.issuer}`);
}
} catch (err) {
console.log(` - 真实握手异常:${err.message} → 跳过`);
}
/* ---------- F. 通道自检 CLI ---------- */
console.log('\n[F] --test-notify 自检命令');
const test = await runOnce(['--test-notify', ...common]);
check('--test-notify 退出码为 0 且报告两渠道', test.code === 0 && /email/.test(test.out) && /meow/i.test(test.out), test.out.split('\n').slice(-4).join(' | ').slice(0, 120));
} finally {
await smtp.close();
await meow.close();
}
const failed = results.filter((r) => !r.pass);
console.log(`\n结果:${results.length - failed.length}/${results.length} 通过`);
if (failed.length) process.exit(1);
}
main().catch((err) => {
console.error('FAILED:', err.stack || err.message);
process.exit(1);
});