diff --git a/.gitignore b/.gitignore index 996c745..e4e8918 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ local.monitors.json backups/ # 助手的工作记录(含本机信息与运维笔记,默认不入库;如需版本化可删除此行) .workbuddy/ +# 运行期私有数据:通知去抖状态与事件时间线 +data/ diff --git a/README.md b/README.md index f494a20..9455828 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,12 @@ node prober/node/index.js --once --demo # 自定义快照输出路径(--demo 会覆盖真实快照,需要保留真实数据时用它写到别处) 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 ``` 启动后打开 即可看到面板。无需安装任何依赖。 @@ -71,7 +77,7 @@ shiroko-monitor/ │ ├── app.js # 数据源解析 / 渲染 / 浏览器直连探测 / 主题切换 │ └── favicon.svg # 默认主题图标 ├── 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/ │ ├── http-probe.js # HTTP 探测(fetch,redirect: manual) │ ├── tcp-probe.js # TCP 探测(net.connect) @@ -119,6 +125,14 @@ shiroko-monitor/ "refreshCooldownSec": 5, // 每个来源的触发冷却(秒) "hideTargets": false, // true = 面板不渲染任何地址(对外发布用) "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) "enabled": false, "apiKey": "", @@ -352,6 +366,76 @@ UptimeRobot 的探测来自其多个节点,适合作为**异地视角**与自 - **浏览器直连模式**:不记录历史,色条显示灰色、「可用率数据积累中」——需要可用率请使用 Node 探针或边缘 Worker - **`--demo`** 会生成 90 天演示分桶(含绿/橙/红各档),仅用于界面预览;若不想覆盖真实快照,用 `--out=` 写到别处 +## 通知、证书与事件 + +### 通知(邮件 + 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": "monitor@example.com", "pass": "密码", + "from": "monitor@example.com", "to": ["ops@example.com"], + "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] `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 已实现 - [x] **「任何情况下都不需要横向滚动」定为硬要求并落地**:表格改为**流式列宽**(`table-layout: fixed` + 百分比列宽),表头允许换行、单元格裁剪,心率图与可用率条随宽度伸缩;从结构上不可能撑出横向滚动 @@ -530,10 +627,11 @@ node prober/node/index.js --once NODE_PATH= node scripts/verify.cjs # 热重载 / 数据一致性 / 刷新语义 / 面板(21 项) NODE_PATH= node scripts/verify-views.cjs # 视图与数据源过滤(10 项) 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 项) ``` -`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 }` 即可被面板直接渲染。 diff --git a/docs/SECURITY.md b/docs/SECURITY.md index e79832c..3aaab2a 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -105,7 +105,8 @@ banner 探测的副作用仅有一次 TCP 连接与读取,与端口扫描器 | 泄露面 | 风险 | 措施 | | --- | --- | --- | | `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),显示「已隐藏」 | | UptimeRobot API Key | 泄露后可读取(只读 Key 不能改配置,但会暴露监控清单) | 只放 `local.monitors.json`(gitignore)或环境变量 `UPTIMEROBOT_API_KEY`;`public/monitors.json` 里 `apiKey` 必须留空 | | `/api/v2/getMonitors`(兼容接口) | 无鉴权,暴露全部目标与可用率 | 内容与公开面板一致,故默认开放;如需收敛请在其前方加带认证的代理,或设 `allowRefresh: false` 并只暴露静态快照 | diff --git a/docs/screenshot-desktop.png b/docs/screenshot-desktop.png index 1f03426..141f0a2 100644 Binary files a/docs/screenshot-desktop.png and b/docs/screenshot-desktop.png differ diff --git a/docs/screenshot-mobile.png b/docs/screenshot-mobile.png index 83b4635..ec85a87 100644 Binary files a/docs/screenshot-mobile.png and b/docs/screenshot-mobile.png differ diff --git a/docs/screenshot-shiroko.png b/docs/screenshot-shiroko.png index 019d813..caef2f2 100644 Binary files a/docs/screenshot-shiroko.png and b/docs/screenshot-shiroko.png differ diff --git a/docs/screenshot-view-cards.png b/docs/screenshot-view-cards.png index 947fe19..324449a 100644 Binary files a/docs/screenshot-view-cards.png and b/docs/screenshot-view-cards.png differ diff --git a/docs/screenshot-view-table.png b/docs/screenshot-view-table.png index be1ed56..b5b59d2 100644 Binary files a/docs/screenshot-view-table.png and b/docs/screenshot-view-table.png differ diff --git a/edge/cloudflare/worker.js b/edge/cloudflare/worker.js index 10d15e3..06b47eb 100644 --- a/edge/cloudflare/worker.js +++ b/edge/cloudflare/worker.js @@ -11,7 +11,7 @@ import { connect } from 'cloudflare:sockets'; -const VERSION = '0.9.1'; +const VERSION = '0.10.0'; const HISTORY_LIMIT = 48; const UPTIME_DAYS = 90; const USER_AGENT = 'shiroko-monitor/0.3 (lightweight uptime panel)'; diff --git a/prober/node/index.js b/prober/node/index.js index 488cee5..9ac000f 100644 --- a/prober/node/index.js +++ b/prober/node/index.js @@ -20,6 +20,8 @@ import { probeHttp } from './lib/http-probe.js'; import { probeTcp } from './lib/tcp-probe.js'; import { probeBanner } from './lib/banner-probe.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 ROOT = path.resolve(__dirname, '../..'); @@ -35,13 +37,15 @@ const CONCURRENCY = 8; const WRITE_DEBOUNCE_MS = 300; const DEFAULT_INTERVAL_SEC = 60; 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_MIN_REFRESH_GAP_MS = 60000; const argv = process.argv.slice(2); const ONCE = argv.includes('--once'); const DEMO = argv.includes('--demo'); +// --test-notify:只给各通知渠道发一条自检消息后退出(不探测、不写快照) +const TEST_NOTIFY = argv.includes('--test-notify'); // --out= 或 --out :指定快照输出路径(默认 public/status.json) function argValue(name) { @@ -54,6 +58,10 @@ const OUT_PATH = path.resolve(argValue('--out') || STATUS_PATH); // --config / --local-config:多实例或测试时指定配置文件(默认 public/monitors.json 与仓库根的私有覆盖) const CONFIG_OVERRIDE = argValue('--config'); const LOCAL_CONFIG_OVERRIDE = argValue('--local-config'); +// --data-dir=:通知状态与事件时间线的存放目录(默认 ./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_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) }; } -/* ---------- 数据源过滤 ---------- */ +/* ---------- 通知(邮件 / 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 探测 function dataFilter(config) { @@ -415,6 +624,14 @@ function snapshot(runtime, demo = false) { }; entry.uptimeRatios = s.uptimeRatios ?? 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; } @@ -438,6 +655,20 @@ function snapshot(runtime, demo = false) { memory: memoryStats(), }, 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, }; } @@ -486,6 +717,7 @@ function forceRound(runtime) { await settleInflight(runtime.state); runtime.forcedAt = Date.now(); scheduleWrite(runtime); + queueEvaluation(runtime, evaluateRound); } catch (err) { console.error(`[refresh] 强制探测异常:${err.message}`); } finally { @@ -654,6 +886,12 @@ function createServer(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); if (rel === '/') rel = '/index.html'; const file = path.join(PUBLIC_DIR, path.normalize(rel)); @@ -688,8 +926,26 @@ async function main() { config: loadConfig(), state: null, 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 }, }; + 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) { writeDemo(runtime.config); return; @@ -700,6 +956,8 @@ async function main() { if (ONCE) { await probeDue(runtime, true); + await queueEvaluation(runtime, evaluateRound); + await queueEvaluation(runtime, checkCertificates); writeStatus(snapshot(runtime)); const s = snapshot(runtime).stats; console.log(`[once] 完成:共 ${s.total},在线 ${s.up},离线 ${s.down}`); @@ -725,7 +983,10 @@ async function main() { setInterval(() => { probeDue(runtime) - .then(() => scheduleWrite(runtime)) + .then(() => { + scheduleWrite(runtime); + queueEvaluation(runtime, evaluateRound); + }) .catch((err) => console.error(`[tick] ${err.stack || err}`)); // UptimeRobot 按自身 pollIntervalSec 拉取,失败不影响本地巡检 if (Date.now() - runtime.ur.lastSyncMs >= uptimeRobotConfig(runtime.config).pollIntervalSec * 1000) { @@ -733,6 +994,11 @@ async function main() { .then(() => scheduleWrite(runtime)) .catch(() => { /* syncUptimeRobot 内部已记录错误 */ }); } + // 证书到期检查:按 checkIntervalSec 周期执行(默认 6 小时),握手失败只记录不影响巡检 + if (Date.now() - runtime.tlsLastCheckMs >= tlsConfig(runtime.config).checkIntervalSec * 1000) { + runtime.tlsLastCheckMs = Date.now(); + queueEvaluation(runtime, checkCertificates); + } }, 1000); createServer(runtime).listen(PORT, () => { diff --git a/prober/node/lib/notify.js b/prober/node/lib/notify.js new file mode 100644 index 0000000..f3f10ba --- /dev/null +++ b/prober/node/lib/notify.js @@ -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; +} diff --git a/prober/node/lib/tls-probe.js b/prober/node/lib/tls-probe.js new file mode 100644 index 0000000..8ce032d --- /dev/null +++ b/prober/node/lib/tls-probe.js @@ -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; + } +} diff --git a/public/assets/app.js b/public/assets/app.js index 84e8336..10ce17d 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -1,4 +1,4 @@ -const VERSION = '0.9.1'; +const VERSION = '0.10.0'; const HB_LIMIT = 48; const UPTIME_DAYS = 90; const BROWSER_CONCURRENCY = 6; @@ -286,7 +286,7 @@ function renderCard(m) { const head = document.createElement('div'); head.className = 'card-head'; // 名称列在窄屏会被截断,title 保证鼠标悬停仍能看到完整名称 - head.innerHTML = `

${escapeHtml(m.name)}

${escapeHtml(typeLabel(m))}`; + head.innerHTML = `

${escapeHtml(m.name)}

${escapeHtml(typeLabel(m))}${certBadge(st)}${maintBadge(st)}`; const target = document.createElement('div'); target.className = 'card-target'; @@ -356,6 +356,25 @@ function isExternal(m) { return m.source === 'uptimerobot'; } +// 证书徽标:仅在临近到期时出现(≤14 天黄色、≤7 天红色),避免常驻打扰 +function certBadge(st) { + const t = st?.tls; + if (!t || t.daysLeft == null) return ''; + if (t.error) return `证书异常`; + 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 `证书 ${t.daysLeft} 天`; +} + +// 维护窗口徽标:该时段内不计入可用率统计、不发告警 +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 `维护中`; +} + function renderTabs() { const nav = $('group-tabs'); const monitors = allMonitors(); @@ -469,7 +488,7 @@ function tableRow(m, hide) { tr.dataset.id = m.id; const targetCell = hide ? '' : `${escapeHtml(targetText(m))}`; tr.innerHTML = `${s.label}` + - `${escapeHtml(m.name)}${escapeHtml(typeLabel(m))}` + + `${escapeHtml(m.name)}${escapeHtml(typeLabel(m))}${certBadge(st)}${maintBadge(st)}` + targetCell + `${st?.latency != null ? `${st.latency} ms` : '–'}` + `${escapeHtml(resultText(st, m))}` + @@ -613,6 +632,22 @@ function renderSummary() { ? `服务端数据最后变更:${fmtTime(state.status?.dataUpdatedAt)}(距现在 ${fmtRelative(state.status?.dataUpdatedAt)})\n快照生成:${fmtTime(st?.generatedAt)}\n服务端更新节奏:每 ${serverCadenceSec() ?? '—'} 秒` : '尚无服务端数据'; $('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 urChip = $('ur-chip'); if (urChip) { diff --git a/public/assets/style.css b/public/assets/style.css index 1208b76..d77b3d6 100644 --- a/public/assets/style.css +++ b/public/assets/style.css @@ -412,6 +412,22 @@ tr[data-status="pending"] .t-state { color: var(--pending-text); } 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-uptime { white-space: nowrap; } /* 可用率条随视口伸缩;极窄时只留百分比,避免挤压其他列 */ diff --git a/public/index.html b/public/index.html index a165c99..6b20ccf 100644 --- a/public/index.html +++ b/public/index.html @@ -54,9 +54,10 @@ 数据源:– + – – - shiroko-monitor v0.9.1 + shiroko-monitor v0.10.0 diff --git a/public/status.json b/public/status.json index 1a77fbb..ebcc92b 100644 --- a/public/status.json +++ b/public/status.json @@ -1,8 +1,8 @@ { - "prober": "shiroko-monitor-node/0.7.0", + "prober": "shiroko-monitor-node/0.10.0", "demo": false, - "generatedAt": "2026-10-13T14:36:59.637Z", - "dataUpdatedAt": "2026-10-13T14:36:43.139Z", + "generatedAt": "2026-10-13T15:54:42.793Z", + "dataUpdatedAt": "2026-10-13T15:53:59.134Z", "updateIntervalSec": 300, "dataFilter": "uptimerobot", "stats": { @@ -10,12 +10,12 @@ "up": 5, "down": 1, "pending": 0, - "avgLatency": 874, + "avgLatency": 763, "avgUptime90": 83.3, "memory": { - "rssMB": 81.3, - "heapUsedMB": 13.8, - "heapTotalMB": 15.7 + "rssMB": 67.4, + "heapUsedMB": 12.7, + "heapTotalMB": 14.2 } }, "sources": { @@ -23,30 +23,98 @@ "enabled": true, "ok": true, "error": null, - "lastSyncAt": "2026-10-13T14:36:16.338Z", + "lastSyncAt": "2026-10-13T15:51:29.877Z", "count": 6, "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": { "ur-792059439": { "status": "up", - "latency": 464, + "latency": 233, "code": null, "detail": "UptimeRobot:运行正常", - "lastCheck": "2026-10-13T14:34:00.000Z", + "lastCheck": "2026-10-13T15:50:00.000Z", "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", "ok": true, @@ -203,20 +271,30 @@ "ms": 271 }, { - "t": "2026-10-13T13:53:00.000Z", + "t": "2026-10-13T13:55:00.000Z", "ok": true, - "ms": 294 + "ms": 307 }, { "t": "2026-10-13T14:34:00.000Z", "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": { "2026-10-13": { - "total": 46, - "up": 46 + "total": 6, + "up": 6 } }, "meta": { @@ -243,186 +321,191 @@ }, "ur-795497022": { "status": "up", - "latency": 704, + "latency": 772, "code": null, "detail": "UptimeRobot:运行正常", - "lastCheck": "2026-10-13T14:33:00.000Z", + "lastCheck": "2026-10-13T15:48:00.000Z", "history": [ { - "t": "2026-10-12T15:40:00.000Z", - "ok": true, - "ms": 798 - }, - { - "t": "2026-10-12T16:20:00.000Z", - "ok": true, - "ms": 685 - }, - { - "t": "2026-10-12T17:00:00.000Z", + "t": "2026-10-12T16:35:00.000Z", "ok": true, "ms": 700 }, { - "t": "2026-10-12T17:40:00.000Z", + "t": "2026-10-12T17:15:00.000Z", "ok": true, - "ms": 694 + "ms": 689 }, { - "t": "2026-10-12T18:20:00.000Z", + "t": "2026-10-12T17:55:00.000Z", "ok": true, - "ms": 703 + "ms": 725 }, { - "t": "2026-10-12T19:00:00.000Z", + "t": "2026-10-12T18:35:00.000Z", "ok": true, - "ms": 729 + "ms": 744 }, { - "t": "2026-10-12T19:40:00.000Z", + "t": "2026-10-12T19:15:00.000Z", "ok": true, - "ms": 713 + "ms": 723 }, { - "t": "2026-10-12T20:20:00.000Z", + "t": "2026-10-12T19:55:00.000Z", "ok": true, - "ms": 784 + "ms": 698 }, { - "t": "2026-10-12T21:00:00.000Z", + "t": "2026-10-12T20:35:00.000Z", "ok": true, - "ms": 628 + "ms": 768 }, { - "t": "2026-10-12T21:40:00.000Z", + "t": "2026-10-12T21:15:00.000Z", "ok": true, - "ms": 629 + "ms": 616 }, { - "t": "2026-10-12T22:20:00.000Z", + "t": "2026-10-12T21:55:00.000Z", "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, "ms": 624 }, { - "t": "2026-10-12T23:40:00.000Z", + "t": "2026-10-12T23:55:00.000Z", "ok": true, - "ms": 715 + "ms": 640 }, { - "t": "2026-10-13T00:20:00.000Z", + "t": "2026-10-13T00:35:00.000Z", "ok": true, - "ms": 683 + "ms": 673 }, { - "t": "2026-10-13T01:00:00.000Z", - "ok": true, - "ms": 643 - }, - { - "t": "2026-10-13T01:40:00.000Z", - "ok": true, - "ms": 641 - }, - { - "t": "2026-10-13T02:20:00.000Z", + "t": "2026-10-13T01:15:00.000Z", "ok": true, "ms": 609 }, { - "t": "2026-10-13T03:00:00.000Z", + "t": "2026-10-13T01:55:00.000Z", "ok": true, - "ms": 606 + "ms": 759 }, { - "t": "2026-10-13T03:40:00.000Z", + "t": "2026-10-13T02:35:00.000Z", "ok": true, - "ms": 720 + "ms": 581 }, { - "t": "2026-10-13T04:20:00.000Z", + "t": "2026-10-13T03:15:00.000Z", "ok": true, - "ms": 711 + "ms": 666 }, { - "t": "2026-10-13T05:00:00.000Z", + "t": "2026-10-13T03:55:00.000Z", "ok": true, - "ms": 598 + "ms": 662 }, { - "t": "2026-10-13T05:40:00.000Z", + "t": "2026-10-13T04:35:00.000Z", "ok": true, - "ms": 729 + "ms": 685 }, { - "t": "2026-10-13T06:20:00.000Z", + "t": "2026-10-13T05:15:00.000Z", "ok": true, - "ms": 676 + "ms": 647 }, { - "t": "2026-10-13T07:00:00.000Z", + "t": "2026-10-13T05:55:00.000Z", "ok": true, - "ms": 691 + "ms": 588 }, { - "t": "2026-10-13T07:40:00.000Z", + "t": "2026-10-13T06:40:00.000Z", "ok": true, - "ms": 696 + "ms": 625 }, { - "t": "2026-10-13T08:20:00.000Z", + "t": "2026-10-13T07:20:00.000Z", "ok": true, - "ms": 677 + "ms": 673 }, { - "t": "2026-10-13T09:00:00.000Z", + "t": "2026-10-13T08:00:00.000Z", "ok": true, - "ms": 969 + "ms": 758 }, { - "t": "2026-10-13T09:40:00.000Z", + "t": "2026-10-13T08:40:00.000Z", "ok": true, - "ms": 669 + "ms": 826 }, { - "t": "2026-10-13T10:20:00.000Z", + "t": "2026-10-13T09:20:00.000Z", "ok": true, - "ms": 840 + "ms": 749 }, { - "t": "2026-10-13T11:00:00.000Z", + "t": "2026-10-13T10:00:00.000Z", "ok": true, - "ms": 735 + "ms": 791 }, { - "t": "2026-10-13T11:40:00.000Z", + "t": "2026-10-13T10:40:00.000Z", "ok": true, - "ms": 693 + "ms": 678 }, { - "t": "2026-10-13T12:20:00.000Z", + "t": "2026-10-13T11:20:00.000Z", "ok": true, - "ms": 793 + "ms": 740 }, { - "t": "2026-10-13T13:56:00.000Z", + "t": "2026-10-13T12:00:00.000Z", "ok": true, - "ms": 1056 + "ms": 828 }, { - "t": "2026-10-13T14:33:00.000Z", + "t": "2026-10-13T12:40:00.000Z", "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": { "2026-10-13": { - "total": 46, - "up": 46 + "total": 6, + "up": 6 } }, "meta": { @@ -449,21 +532,11 @@ }, "ur-795556828": { "status": "up", - "latency": 338, + "latency": 230, "code": null, "detail": "UptimeRobot:运行正常", - "lastCheck": "2026-10-13T14:34:00.000Z", + "lastCheck": "2026-10-13T15:50:00.000Z", "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", "ok": true, @@ -620,20 +693,30 @@ "ms": 413 }, { - "t": "2026-10-13T13:54:00.000Z", + "t": "2026-10-13T13:55:00.000Z", "ok": true, - "ms": 187 + "ms": 283 }, { "t": "2026-10-13T14:34:00.000Z", "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": { "2026-10-13": { - "total": 46, - "up": 46 + "total": 6, + "up": 6 } }, "meta": { @@ -660,21 +743,11 @@ }, "ur-792059414": { "status": "up", - "latency": 2424, + "latency": 2238, "code": null, "detail": "UptimeRobot:运行正常", - "lastCheck": "2026-10-13T14:34:00.000Z", + "lastCheck": "2026-10-13T15:48:00.000Z", "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", "ok": true, @@ -796,45 +869,50 @@ "ms": 1597 }, { - "t": "2026-10-13T10:35:00.000Z", + "t": "2026-10-13T10:40:00.000Z", "ok": true, - "ms": 3015 + "ms": 2816 }, { - "t": "2026-10-13T11:15:00.000Z", + "t": "2026-10-13T11:25:00.000Z", "ok": true, - "ms": 1965 + "ms": 1912 }, { - "t": "2026-10-13T11:55:00.000Z", + "t": "2026-10-13T12:10:00.000Z", "ok": true, - "ms": 2118 + "ms": 2326 }, { - "t": "2026-10-13T12:35:00.000Z", + "t": "2026-10-13T12:55:00.000Z", "ok": true, - "ms": 2557 + "ms": 2938 }, { - "t": "2026-10-13T13:15:00.000Z", + "t": "2026-10-13T13:40:00.000Z", "ok": true, - "ms": 1671 + "ms": 3146 }, { - "t": "2026-10-13T13:55:00.000Z", + "t": "2026-10-13T14:25:00.000Z", "ok": true, - "ms": 1908 + "ms": 2125 }, { - "t": "2026-10-13T14:34:00.000Z", + "t": "2026-10-13T15:08:00.000Z", "ok": true, - "ms": 2424 + "ms": 2892 + }, + { + "t": "2026-10-13T15:48:00.000Z", + "ok": true, + "ms": 2238 } ], "daily": { "2026-10-13": { - "total": 46, - "up": 46 + "total": 6, + "up": 6 } }, "meta": { @@ -861,191 +939,191 @@ }, "ur-798788113": { "status": "up", - "latency": 438, + "latency": 340, "code": null, "detail": "UptimeRobot:运行正常", - "lastCheck": "2026-10-13T14:35:00.000Z", + "lastCheck": "2026-10-13T15:46:00.000Z", "history": [ { - "t": "2026-10-12T15:45:00.000Z", + "t": "2026-10-12T16:50:00.000Z", "ok": true, - "ms": 462 + "ms": 463 }, { - "t": "2026-10-12T16:25:00.000Z", + "t": "2026-10-12T17:30:00.000Z", "ok": true, - "ms": 288 + "ms": 320 }, { - "t": "2026-10-12T17:05:00.000Z", + "t": "2026-10-12T18:10:00.000Z", "ok": true, - "ms": 409 + "ms": 404 }, { - "t": "2026-10-12T17: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", + "t": "2026-10-12T18:50:00.000Z", "ok": true, "ms": 343 }, { - "t": "2026-10-13T06:30:00.000Z", + "t": "2026-10-12T19:30:00.000Z", "ok": true, - "ms": 434 + "ms": 476 }, { - "t": "2026-10-13T07:10:00.000Z", + "t": "2026-10-12T20:10:00.000Z", "ok": true, - "ms": 444 + "ms": 285 }, { - "t": "2026-10-13T07:50:00.000Z", + "t": "2026-10-12T20:50:00.000Z", "ok": true, - "ms": 281 + "ms": 449 }, { - "t": "2026-10-13T08:30:00.000Z", + "t": "2026-10-12T21:30:00.000Z", "ok": true, - "ms": 453 + "ms": 332 }, { - "t": "2026-10-13T09:15:00.000Z", + "t": "2026-10-12T22:10:00.000Z", "ok": true, - "ms": 333 + "ms": 450 }, { - "t": "2026-10-13T09:55:00.000Z", + "t": "2026-10-12T22:50:00.000Z", "ok": true, - "ms": 602 + "ms": 231 }, { - "t": "2026-10-13T10:35:00.000Z", + "t": "2026-10-12T23:30:00.000Z", "ok": true, - "ms": 519 + "ms": 249 }, { - "t": "2026-10-13T11:15:00.000Z", + "t": "2026-10-13T00:10:00.000Z", "ok": true, - "ms": 325 + "ms": 286 }, { - "t": "2026-10-13T11:55:00.000Z", + "t": "2026-10-13T00:50:00.000Z", "ok": true, - "ms": 933 + "ms": 223 }, { - "t": "2026-10-13T12:35:00.000Z", + "t": "2026-10-13T01:30:00.000Z", "ok": true, - "ms": 424 + "ms": 386 }, { - "t": "2026-10-13T13:15:00.000Z", + "t": "2026-10-13T02:10:00.000Z", "ok": true, - "ms": 460 + "ms": 511 }, { - "t": "2026-10-13T13:55:00.000Z", + "t": "2026-10-13T02:50:00.000Z", "ok": true, - "ms": 328 + "ms": 292 }, { - "t": "2026-10-13T14:35:00.000Z", + "t": "2026-10-13T03:30:00.000Z", "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": { "2026-10-13": { - "total": 46, - "up": 46 + "total": 6, + "up": 6 } }, "meta": { @@ -1085,7 +1163,7 @@ ], "daily": { "2026-10-13": { - "total": 46, + "total": 6, "up": 0 } }, diff --git a/scripts/verify-notify.cjs b/scripts/verify-notify.cjs new file mode 100644 index 0000000..7286094 --- /dev/null +++ b/scripts/verify-notify.cjs @@ -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 .'); + 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('not json'); + }); + }); + 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: 'monitor@example.com', pass: 'secret', from: 'monitor@example.com', + to: ['ops@example.com', 'ops2@example.com'], + }, + 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: 'monitor@example.com', pass: 's3cret!', from: 'monitor@example.com', to: ['ops@example.com', 'ops2@example.com'], + }; + await sendEmail(emailCfg, { subject: '【故障】测试目标 连接失败', text: '第一行\n第二行:中文内容' }); + const mail = smtp.received.at(-1); + check('邮件已发出', Boolean(mail?.raw), '假服务器收到 DATA'); + check('发件人与收件人正确', mail?.mailFrom === 'monitor@example.com' && mail?.rcpt?.join(',') === 'ops@example.com,ops2@example.com', `${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); +});