1) 表格显示不全 table-layout:fixed 保证不横向滚动后,列内仍是 nowrap,实测长 URL 被截 388px、名称截 219px、可用率条截 35px(1440 宽共 14 个单元格)。 改为允许换行(地址 break-all)并重排列宽 —— 数据完整且仍零横向滚动。 2) 最近事件完全没样式 根因:上次追加 CSS 的命令是 `node --check app.js && cat >> style.css`, --check 报错导致 && 短路,CSS 从未写入(只有 HTML 裸奔)。 本次用编辑器写入;同时重做为时间线样式(竖线 + 类型着色圆点 + 左描边)、 名称列按内容自适应(消除大片空白)、默认折叠最近 8 条可展开全部。 3) dataFilter=uptimerobot 时仍在探测本地目标 新增 settings.probeScope(all|config|uptimerobot),不填则跟随 dataFilter; 显式设 all 可保留探测。启动日志打印当前口径与范围。 顺带修复两个连带问题: - UR 镜像目标此前不产生事件也不告警(evaluateRound 只遍历本地目标), 配 uptimerobot 口径等于收不到任何提醒 —— 现在两类目标统一纳入 - 去抖计数被评估频率放大:evaluateRound 每秒执行,原实现每次评估都 failStreak+1,持续离线目标 1 秒即达阈值告警。改为按探测时间戳去重, 且无状态变化时不再反复落盘 测试:verify-views [J] 扩到 11 条(长内容不截断/仍不滚动/时间线布局/ 类型着色/折叠展开),verify-notify 新增 [D3] 4 条(范围跟随、不再探测本地、 显式 all 恢复、与展示口径解耦)+ 去抖计数断言。六套 170 项全通过。
402 lines
22 KiB
JavaScript
402 lines
22 KiB
JavaScript
/**
|
||
* 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)));
|
||
// 去抖计数必须与「探测轮次」绑定:评估每秒跑一次,不能每秒累加
|
||
const ns2 = JSON.parse(fs.readFileSync(path.join(dataDir, 'notify-state.json'), 'utf8'));
|
||
check('连续失败计数=探测轮数(不被每秒评估放大)', ns2.monitors['down-tcp'].failStreak === 2, `failStreak=${ns2.monitors['down-tcp'].failStreak},期望 2`);
|
||
|
||
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 === '计划维护', '');
|
||
|
||
/* ---------- D3. 探测范围可选(probeScope) ---------- */
|
||
console.log('\n[D3] 探测范围(probeScope)');
|
||
const tmp3 = fs.mkdtempSync(path.join(os.tmpdir(), 'shiroko-scope-'));
|
||
fs.cpSync(path.join(ROOT, 'public'), tmp3, { recursive: true });
|
||
const { file: file3 } = writeConfig(tmp3);
|
||
const scopeCfg = JSON.parse(fs.readFileSync(file3, 'utf8'));
|
||
scopeCfg.settings.dataFilter = 'uptimerobot'; // 只展示 UR 口径
|
||
scopeCfg.settings.uptimeRobot = { enabled: false }; // 本测试不连 UR,只看本地探测是否发生
|
||
scopeCfg.monitors.push({ id: 'local-must-not-run', name: '不该被探测的本地目标', group: '测试', type: 'tcp', host: '127.0.0.1', port: 1, interval: 60, timeout: 3 });
|
||
fs.writeFileSync(file3, `${JSON.stringify(scopeCfg, null, 2)}\n`);
|
||
const scopeArgs = (cfgFile, out, data) => ['--once', `--config=${cfgFile}`, `--local-config=${cfgFile}`, `--out=${out}`, `--data-dir=${data}`];
|
||
|
||
const outA = path.join(tmp3, 'a.json');
|
||
const resA = await runOnce(scopeArgs(file3, outA, path.join(tmp3, 'da')));
|
||
const snapA = JSON.parse(fs.readFileSync(outA, 'utf8'));
|
||
check('dataFilter=uptimerobot 时探测范围自动跟随', snapA.probeScope === 'uptimerobot', `probeScope=${snapA.probeScope}`);
|
||
check('跟随口径时不再探测本地配置目标', !/local-must-not-run/.test(resA.out), resA.out.split('\n').filter((l) => /\[up\]|\[down\]/.test(l)).join(' | ').slice(0, 80) || '本地探测日志为空');
|
||
|
||
// 对照:显式 probeScope=all 时应恢复本地探测(证明是「可选」而非「砍掉」)
|
||
scopeCfg.settings.probeScope = 'all';
|
||
fs.writeFileSync(file3, `${JSON.stringify(scopeCfg, null, 2)}\n`);
|
||
const outB = path.join(tmp3, 'b.json');
|
||
const resB = await runOnce(scopeArgs(file3, outB, path.join(tmp3, 'db')));
|
||
const snapB = JSON.parse(fs.readFileSync(outB, 'utf8'));
|
||
check('显式 probeScope=all 时恢复本地探测', snapB.probeScope === 'all' && /local-must-not-run/.test(resB.out), `probeScope=${snapB.probeScope}`);
|
||
check('探测范围与展示口径解耦(all + uptimerobot 展示)', snapB.dataFilter === 'uptimerobot' && snapB.probeScope === 'all', `filter=${snapB.dataFilter} scope=${snapB.probeScope}`);
|
||
|
||
/* ---------- 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);
|
||
});
|