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
+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);
});