Files
shiroko-monitor/scripts/verify-notify.cjs
T
tamakyi bdaa31f10c 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 项全通过。
2026-10-13 23:54:46 +08:00

372 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
});