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:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user