/** * 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); });