:通知状态与事件时间线的存放目录(默认 ./data,也可用环境变量 SHIROKO_DATA_DIR)
+if (argValue('--data-dir')) process.env.SHIROKO_DATA_DIR = argValue('--data-dir');
+// 私有数据目录:通知状态(告警去抖)与事件时间线,含本机运行痕迹,默认不入库
+const DATA_DIR = process.env.SHIROKO_DATA_DIR ? path.resolve(process.env.SHIROKO_DATA_DIR) : path.join(ROOT, 'data');
const ACTIVE_CONFIG_PATH = CONFIG_OVERRIDE ? path.resolve(CONFIG_OVERRIDE) : CONFIG_PATH;
const ACTIVE_LOCAL_CONFIG_PATH = LOCAL_CONFIG_OVERRIDE ? path.resolve(LOCAL_CONFIG_OVERRIDE) : LOCAL_CONFIG_PATH;
@@ -308,7 +316,208 @@ function memoryStats() {
return { rssMB: mb(m.rss), heapUsedMB: mb(m.heapUsed), heapTotalMB: mb(m.heapTotal) };
}
-/* ---------- 数据源过滤 ---------- */
+/* ---------- 通知(邮件 / MeoW)、事件时间线、维护窗口与证书监控 ---------- */
+
+const DEFAULT_TLS_CHECK_SEC = 21600; // 证书默认 6 小时查一次
+const TLS_ALERT_DAYS = [14, 7, 3];
+const EVENT_LIMIT = 500;
+const EVENT_IN_SNAPSHOT = 20;
+
+function notifyConfig(config) {
+ const n = config.settings?.notify ?? {};
+ const channels = n.channels ?? {};
+ return {
+ enabled: n.enabled === true && Object.values(channels).some((c) => c?.enabled === true),
+ failureThreshold: Math.max(1, Number(n.failureThreshold) || 2),
+ recoveryNotify: n.recoveryNotify !== false,
+ quietHours: n.quietHours ?? null,
+ channels,
+ };
+}
+
+function tlsConfig(config) {
+ const t = config.settings?.tls ?? {};
+ const alertDays = Array.isArray(t.alertDays) ? t.alertDays.map(Number).filter(Number.isFinite) : null;
+ return {
+ enabled: t.enabled !== false, // 默认对 https 目标开启
+ checkIntervalSec: Math.max(600, Number(t.checkIntervalSec) || DEFAULT_TLS_CHECK_SEC),
+ alertDays: alertDays?.length ? alertDays.sort((a, b) => b - a) : TLS_ALERT_DAYS,
+ };
+}
+
+// 维护窗口:{ from, to, id?: string|string[], note? };不写 id 表示对所有目标生效
+function activeMaintenanceFor(config, id, at = new Date()) {
+ const list = config.settings?.maintenance;
+ if (!Array.isArray(list) || !list.length) return null;
+ const t = at.getTime();
+ for (const w of list) {
+ const from = Date.parse(w.from);
+ const to = Date.parse(w.to);
+ if (!Number.isFinite(from) || !Number.isFinite(to) || t < from || t > to) continue;
+ if (!w.id) return w;
+ const ids = Array.isArray(w.id) ? w.id : [w.id];
+ if (ids.includes(id)) return w;
+ }
+ return null;
+}
+
+function readJson(file, fallback) {
+ try {
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
+ } catch {
+ return fallback;
+ }
+}
+
+function writeJson(file, data) {
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
+}
+
+const NOTIFY_STATE_PATH = () => path.join(DATA_DIR, 'notify-state.json');
+const EVENTS_PATH = () => path.join(DATA_DIR, 'events.json');
+
+function monitorNotifyState(runtime, id) {
+ const list = runtime.notifyState.monitors;
+ if (!list[id]) list[id] = { lastStatus: null, failStreak: 0, notifiedTls: [], tlsValidTo: null };
+ return list[id];
+}
+
+function appendEvents(runtime, entries) {
+ if (!entries.length) return;
+ runtime.events = [...runtime.events, ...entries].slice(-EVENT_LIMIT);
+ writeJson(EVENTS_PATH(), runtime.events);
+}
+
+// 通知 + 事件:每轮探测结束后评估状态翻转(带去抖)、恢复、维护窗口抑制
+async function evaluateRound(runtime) {
+ const notify = notifyConfig(runtime.config);
+ const events = [];
+ const pendingNotifications = [];
+
+ for (const m of runtime.config.monitors) {
+ const r = runtime.state.get(m.id)?.lastResult;
+ if (!r || r.status === 'pending') continue;
+ const st = monitorNotifyState(runtime, m.id);
+ st.failStreak = r.status === 'down' ? (st.failStreak ?? 0) + 1 : 0;
+ const maintenance = activeMaintenanceFor(runtime.config, m.id);
+ const at = new Date().toISOString();
+
+ if (r.status === 'down') {
+ // 去抖:连续失败达到阈值才算确认故障(阈值 1 表示立即告警)
+ if (st.lastStatus === 'down' || st.failStreak < notify.failureThreshold) continue;
+ st.lastStatus = 'down';
+ st.lastNotifiedAt = at;
+ events.push({ t: at, id: m.id, name: m.name, type: 'down', detail: r.detail ?? null, maintenance: Boolean(maintenance) });
+ if (!maintenance) {
+ pendingNotifications.push({
+ subject: `【故障】${m.name} 连接失败`,
+ text: `目标:${m.name}\n地址:${m.url ?? `${m.host}:${m.port}`}\n时间:${at}\n详情:${r.detail ?? '无'}\n连续失败:${st.failStreak} 次\n\n—— shiroko-monitor`,
+ });
+ }
+ } else if (r.status === 'up') {
+ if (st.lastStatus === null) {
+ st.lastStatus = 'up'; // 首次观测不产生「恢复」噪音
+ continue;
+ }
+ if (st.lastStatus === 'down') {
+ st.lastStatus = 'up';
+ st.lastNotifiedAt = at;
+ events.push({ t: at, id: m.id, name: m.name, type: 'up', detail: '已恢复', maintenance: Boolean(maintenance) });
+ if (notify.recoveryNotify && !maintenance) {
+ pendingNotifications.push({
+ subject: `【恢复】${m.name} 已恢复正常`,
+ text: `目标:${m.name}\n地址:${m.url ?? `${m.host}:${m.port}`}\n恢复时间:${at}\n延迟:${r.latency ?? '—'} ms\n\n—— shiroko-monitor`,
+ });
+ }
+ }
+ }
+ }
+
+ if (events.length) {
+ appendEvents(runtime, events);
+ console.log(`[events] 新增 ${events.length} 条:${events.map((e) => `${e.name} ${e.type}`).join('、')}`);
+ }
+ // 汇总成一条通知,避免同一轮多点故障刷屏
+ if (pendingNotifications.length) {
+ const message = pendingNotifications.length === 1
+ ? pendingNotifications[0]
+ : {
+ subject: `【${pendingNotifications.length} 个目标状态变化】shiroko-monitor`,
+ text: pendingNotifications.map((p) => p.text).join('\n\n──────────\n\n'),
+ };
+ const res = await notifyAll(notify, message, { logger: console });
+ runtime.notifyState.lastResult = { at: new Date().toISOString(), ...res };
+ }
+ // 无论是否发通知都要落盘:否则「连续失败计数」会丢,重启或下一轮就达不到阈值
+ saveNotifyState(runtime);
+}
+
+// 证书检查:按 checkIntervalSec 逐个握手,跨阈值时告警(同一张证书每个阈值只告一次)
+async function checkCertificates(runtime) {
+ const tlsCfg = tlsConfig(runtime.config);
+ if (!tlsCfg.enabled) return;
+ const now = Date.now();
+ const events = [];
+ const messages = [];
+
+ for (const m of runtime.config.monitors) {
+ const target = tlsTargetOf(m);
+ if (!target) continue;
+ const prev = runtime.tls.get(m.id);
+ if (prev && now - (prev.checkedAtMs ?? 0) < tlsCfg.checkIntervalSec * 1000) continue;
+ const result = await probeCertificate({ ...target, timeoutMs: Math.max(5, Number(m.timeout) || 8) * 1000 });
+ runtime.tls.set(m.id, { ...result, checkedAt: new Date().toISOString(), checkedAtMs: now });
+
+ const st = monitorNotifyState(runtime, m.id);
+ if (result.error) {
+ console.warn(`[tls] ${m.id} 证书检查失败:${result.error}`);
+ continue;
+ }
+ // 换了证书(validTo 变化)→ 重置已通知阈值,新证书重新提醒
+ if (st.tlsValidTo !== result.validTo) {
+ st.tlsValidTo = result.validTo;
+ st.notifiedTls = [];
+ }
+ const maintenance = activeMaintenanceFor(runtime.config, m.id);
+ // 已过期的证书只报最严重的一档(避免一次刷三条)
+ const thresholds = result.daysLeft <= 0 ? tlsCfg.alertDays.slice(-1) : tlsCfg.alertDays;
+ for (const threshold of thresholds) {
+ if (result.daysLeft > threshold || (st.notifiedTls ?? []).includes(threshold)) continue;
+ st.notifiedTls = [...(st.notifiedTls ?? []), threshold];
+ const at = new Date().toISOString();
+ events.push({ t: at, id: m.id, name: m.name, type: 'tls', detail: `证书剩余 ${result.daysLeft} 天(阈值 ${threshold} 天)`, maintenance: Boolean(maintenance) });
+ if (!maintenance) {
+ messages.push(`- ${m.name}:证书剩余 ${result.daysLeft} 天(到期 ${result.validTo.slice(0, 10)},签发者 ${result.issuer ?? '未知'})`);
+ }
+ }
+ }
+
+ if (events.length) {
+ appendEvents(runtime, events);
+ if (messages.length) {
+ const res = await notifyAll(notifyConfig(runtime.config), {
+ subject: `【证书提醒】${messages.length} 个证书即将到期`,
+ text: `${messages.join('\n')}\n\n签发者与到期时间取自实际握手,请及时续期。\n\n—— shiroko-monitor`,
+ }, { logger: console });
+ runtime.notifyState.lastResult = { at: new Date().toISOString(), ...res };
+ }
+ saveNotifyState(runtime);
+ }
+}
+
+function saveNotifyState(runtime) {
+ writeJson(NOTIFY_STATE_PATH(), runtime.notifyState);
+}
+
+// 通知与证书检查串行执行:避免并发轮次重复发同一条告警
+function queueEvaluation(runtime, task) {
+ runtime.notifyChain = (runtime.notifyChain ?? Promise.resolve())
+ .then(() => task(runtime))
+ .catch((err) => console.error(`[notify] 评估异常:${err.stack || err.message}`));
+ return runtime.notifyChain;
+}
+
// 只影响「展示与快照输出」,不影响探测:配置文件里的目标始终按各自 interval 探测
function dataFilter(config) {
@@ -415,6 +624,14 @@ function snapshot(runtime, demo = false) {
};
entry.uptimeRatios = s.uptimeRatios ?? null;
entry.downSeconds = s.downSeconds ?? null;
+ } else {
+ // 证书与维护窗口信息(来自独立的握手检查与配置),供面板打标
+ const tlsInfo = runtime.tls?.get(id);
+ if (tlsInfo) entry.tls = redact
+ ? { daysLeft: tlsInfo.daysLeft ?? null, error: tlsInfo.error ? '不可用' : null }
+ : { daysLeft: tlsInfo.daysLeft ?? null, validTo: tlsInfo.validTo ?? null, issuer: tlsInfo.issuer ?? null, checkedAt: tlsInfo.checkedAt ?? null, error: tlsInfo.error ?? null };
+ const maintenance = activeMaintenanceFor(config, id);
+ if (maintenance) entry.maintenance = { note: maintenance.note ?? null, to: maintenance.to ?? null };
}
monitors[id] = entry;
}
@@ -438,6 +655,20 @@ function snapshot(runtime, demo = false) {
memory: memoryStats(),
},
sources: { uptimerobot: { ...(runtime.ur ?? {}) } },
+ // 通知能力与最近事件(事件含目标名与原因,redactDetails 时只保留类型与时间)
+ notify: (() => {
+ const n = notifyConfig(config);
+ return {
+ enabled: n.enabled,
+ channels: Object.entries(n.channels ?? {}).filter(([, c]) => c?.enabled).map(([k]) => k),
+ quietHours: n.quietHours,
+ failureThreshold: n.failureThreshold,
+ lastResult: runtime.notifyState?.lastResult ?? null,
+ };
+ })(),
+ events: (runtime.events ?? []).slice(-EVENT_IN_SNAPSHOT).reverse().map((e) => (redact
+ ? { t: e.t, id: e.id, type: e.type, name: e.name, detail: e.type === 'down' ? '异常' : e.type === 'up' ? '恢复' : e.detail, maintenance: e.maintenance }
+ : e)),
monitors,
};
}
@@ -486,6 +717,7 @@ function forceRound(runtime) {
await settleInflight(runtime.state);
runtime.forcedAt = Date.now();
scheduleWrite(runtime);
+ queueEvaluation(runtime, evaluateRound);
} catch (err) {
console.error(`[refresh] 强制探测异常:${err.message}`);
} finally {
@@ -654,6 +886,12 @@ function createServer(runtime) {
return sendJson(res, toUptimeRobotResponse(runtime));
}
+ // 事件时间线:面板「事件」视图取这里(含通知去抖前的全部状态翻转记录)
+ if (url.pathname === '/api/events') {
+ const list = [...(runtime.events ?? [])].reverse();
+ return sendJson(res, { total: list.length, events: list.slice(0, 200) });
+ }
+
let rel = decodeURIComponent(url.pathname);
if (rel === '/') rel = '/index.html';
const file = path.join(PUBLIC_DIR, path.normalize(rel));
@@ -688,8 +926,26 @@ async function main() {
config: loadConfig(),
state: null,
dataUpdatedAt: Date.now(),
+ // 通知去抖状态与事件时间线从私有数据目录恢复,重启不会重复告警
+ notifyState: readJson(NOTIFY_STATE_PATH(), { monitors: {} }),
+ events: readJson(EVENTS_PATH(), []),
+ tls: new Map(),
+ tlsLastCheckMs: 0,
ur: { enabled: false, ok: null, error: null, lastSyncAt: null, count: 0, syncing: false, lastSyncMs: 0 },
};
+ if (TEST_NOTIFY) {
+ // 通道自检:只发一条测试消息,不探测、不写快照
+ const cfg = notifyConfig(runtime.config);
+ if (!cfg.enabled) {
+ console.error('未启用任何通知渠道(settings.notify.enabled 或 channels.*.enabled 未开启)');
+ process.exitCode = 1;
+ return;
+ }
+ console.log('通知通道自检:');
+ const out = await sendTestNotifications(cfg, { logger: console });
+ if (out.some((r) => !r.ok)) process.exitCode = 1;
+ return;
+ }
if (DEMO) {
writeDemo(runtime.config);
return;
@@ -700,6 +956,8 @@ async function main() {
if (ONCE) {
await probeDue(runtime, true);
+ await queueEvaluation(runtime, evaluateRound);
+ await queueEvaluation(runtime, checkCertificates);
writeStatus(snapshot(runtime));
const s = snapshot(runtime).stats;
console.log(`[once] 完成:共 ${s.total},在线 ${s.up},离线 ${s.down}`);
@@ -725,7 +983,10 @@ async function main() {
setInterval(() => {
probeDue(runtime)
- .then(() => scheduleWrite(runtime))
+ .then(() => {
+ scheduleWrite(runtime);
+ queueEvaluation(runtime, evaluateRound);
+ })
.catch((err) => console.error(`[tick] ${err.stack || err}`));
// UptimeRobot 按自身 pollIntervalSec 拉取,失败不影响本地巡检
if (Date.now() - runtime.ur.lastSyncMs >= uptimeRobotConfig(runtime.config).pollIntervalSec * 1000) {
@@ -733,6 +994,11 @@ async function main() {
.then(() => scheduleWrite(runtime))
.catch(() => { /* syncUptimeRobot 内部已记录错误 */ });
}
+ // 证书到期检查:按 checkIntervalSec 周期执行(默认 6 小时),握手失败只记录不影响巡检
+ if (Date.now() - runtime.tlsLastCheckMs >= tlsConfig(runtime.config).checkIntervalSec * 1000) {
+ runtime.tlsLastCheckMs = Date.now();
+ queueEvaluation(runtime, checkCertificates);
+ }
}, 1000);
createServer(runtime).listen(PORT, () => {
diff --git a/prober/node/lib/notify.js b/prober/node/lib/notify.js
new file mode 100644
index 0000000..f3f10ba
--- /dev/null
+++ b/prober/node/lib/notify.js
@@ -0,0 +1,324 @@
+/**
+ * 通知发送:邮件(手写 SMTP,零依赖)+ MeoW 推送
+ *
+ * 为什么手写 SMTP:本项目刻意保持零运行时依赖(Node 内置模块够用),
+ * 引入 nodemailer 会带来一串传递依赖,对一个自用面板不划算。
+ * 只实现发信必需的流程:EHLO → (STARTTLS) → AUTH → MAIL/RCPT/DATA → QUIT。
+ *
+ * MeoW 接口要点(据 https://www.chuckfang.com/MeoW/api_doc.html ):
+ * - 无需 token,昵称即寻址:POST https://api.chuckfang.com/{昵称}
+ * - HTTP 状态恒为 200,成功与否看响应体的 status 字段
+ * - msgType / htmlHeight 是查询参数,不是 body 参数
+ * - 非会员限流:3 秒 1 条、每分钟 15 条、每小时 60 条
+ * - 官方提醒:不要用多人共享的出口 IP(CI 环境建议只用邮件)
+ */
+
+import net from 'node:net';
+import tls from 'node:tls';
+
+const CRLF = '\r\n';
+
+/* ---------- 邮件 ---------- */
+
+function encodeHeader(value) {
+ // 非 ASCII 主题按 RFC 2047 base64 编码,避免中文主题乱码
+ return /^[\x20-\x7E]*$/.test(value) ? value : `=?UTF-8?B?${Buffer.from(value, 'utf8').toString('base64')}?=`;
+}
+
+function base64Body(text) {
+ // 正文用 base64:既免去 quoted-printable 的编码复杂度,也不会出现以 "." 开头的行(无需 dot-stuffing)
+ return (Buffer.from(text, 'utf8').toString('base64').match(/.{1,76}/g) ?? []).join(CRLF);
+}
+
+function buildMessage({ from, to, subject, text }) {
+ return [
+ `From: ${from}`,
+ `To: ${to.join(', ')}`,
+ `Subject: ${encodeHeader(subject)}`,
+ `Date: ${new Date().toUTCString()}`,
+ 'MIME-Version: 1.0',
+ 'Content-Type: text/plain; charset=UTF-8',
+ 'Content-Transfer-Encoding: base64',
+ '',
+ base64Body(text),
+ '',
+ ].join(CRLF);
+}
+
+/** 极简 SMTP 会话:读 3 位状态码响应(支持多行 `250-` 连续响应) */
+function createSession(socket, timeoutMs) {
+ let buffer = '';
+ let waiter = null;
+ socket.setEncoding('utf8');
+ socket.on('data', (chunk) => {
+ buffer += chunk;
+ if (!waiter) return;
+ // 一条完整响应以 `\d{3} `(空格结尾)结束
+ const lines = buffer.split(CRLF);
+ const complete = lines.some((l) => /^\d{3} /.test(l));
+ if (complete) {
+ const reply = buffer;
+ buffer = '';
+ const w = waiter;
+ waiter = null;
+ w.resolve(reply);
+ }
+ });
+ const fail = (err) => {
+ if (waiter) {
+ const w = waiter;
+ waiter = null;
+ w.reject(err);
+ }
+ };
+ socket.on('error', fail);
+ socket.on('close', () => fail(new Error('SMTP 连接被关闭')));
+ // 空闲超时必须主动断开:否则服务器不响应时读响应会一直挂着,把探针循环卡死
+ socket.on('timeout', () => {
+ socket.destroy();
+ fail(new Error(`SMTP 响应超时(${timeoutMs}ms)`));
+ });
+ return {
+ read: () => new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ waiter = null;
+ reject(new Error(`SMTP 响应超时(${timeoutMs}ms)`));
+ }, timeoutMs);
+ waiter = {
+ resolve: (v) => { clearTimeout(timer); resolve(v); },
+ reject: (e) => { clearTimeout(timer); reject(e); },
+ };
+ }),
+ write: (line) => socket.write(line + CRLF),
+ code: (reply) => Number(reply.slice(0, 3)),
+ raw: () => socket,
+ };
+}
+
+async function connect(socket) {
+ await new Promise((resolve, reject) => {
+ socket.once('connect', resolve);
+ socket.once('secureConnect', resolve);
+ socket.once('error', reject);
+ });
+ return socket;
+}
+
+export async function sendEmail(cfg, { subject, text }, { timeoutMs = 15000 } = {}) {
+ const host = cfg.host;
+ const port = Number(cfg.port) || 465;
+ if (!host) throw new Error('邮件通道缺少 host');
+ if (!cfg.from) throw new Error('邮件通道缺少发件人 from');
+ const to = Array.isArray(cfg.to) ? cfg.to.filter(Boolean) : [cfg.to].filter(Boolean);
+ if (!to.length) throw new Error('邮件通道缺少收件人 to');
+
+ // 465 走隐式 TLS;587/25 先明文再 STARTTLS
+ const implicitTls = port === 465 || cfg.secure === true;
+ let socket = implicitTls
+ ? tls.connect({ host, port, servername: cfg.servername || host, rejectUnauthorized: cfg.rejectUnauthorized !== false })
+ : net.connect({ host, port });
+ socket.setTimeout(timeoutMs);
+ await connect(socket);
+
+ let session = createSession(socket, timeoutMs);
+ const expect = async (wanted) => {
+ const reply = await session.read();
+ const code = session.code(reply);
+ if (code !== wanted) throw new Error(`SMTP 期望 ${wanted} 但收到:${reply.trim().split(CRLF).pop()}`);
+ return reply;
+ };
+
+ await expect(220);
+ session.write(`EHLO ${cfg.helo || 'shiroko-monitor'}`);
+ let caps = await expect(250);
+
+ if (!implicitTls) {
+ if (/STARTTLS/i.test(caps)) {
+ session.write('STARTTLS');
+ await expect(220);
+ socket = await new Promise((resolve, reject) => {
+ const upgraded = tls.connect({ socket, servername: cfg.servername || host, rejectUnauthorized: cfg.rejectUnauthorized !== false }, () => resolve(upgraded));
+ upgraded.once('error', reject);
+ });
+ socket.setTimeout(timeoutMs);
+ session = createSession(socket, timeoutMs);
+ session.write(`EHLO ${cfg.helo || 'shiroko-monitor'}`);
+ caps = await expect(250);
+ } else if (cfg.allowInsecure !== true) {
+ // 默认拒绝明文发信(凭据会裸奔);仅测试或本机可信中继场景下显式开启
+ throw new Error('SMTP 服务器不支持 STARTTLS,已中止(如需明文传输请显式设置 allowInsecure: true)');
+ }
+ }
+
+ if (cfg.user) {
+ if (/AUTH[ =-].*PLAIN/i.test(caps) && !/AUTH[ =-].*LOGIN/i.test(caps)) {
+ session.write(`AUTH PLAIN ${Buffer.from(`\0${cfg.user}\0${cfg.pass ?? ''}`, 'utf8').toString('base64')}`);
+ } else {
+ session.write('AUTH LOGIN');
+ await expect(334);
+ session.write(Buffer.from(cfg.user, 'utf8').toString('base64'));
+ await expect(334);
+ session.write(Buffer.from(cfg.pass ?? '', 'utf8').toString('base64'));
+ }
+ await expect(235);
+ }
+
+ session.write(`MAIL FROM:<${cfg.from}>`);
+ await expect(250);
+ for (const rcpt of to) {
+ session.write(`RCPT TO:<${rcpt}>`);
+ const reply = await session.read();
+ if (![250, 251].includes(session.code(reply))) throw new Error(`收件人被拒(${rcpt}):${reply.trim()}`);
+ }
+ session.write('DATA');
+ await expect(354);
+ socket.write(buildMessage({ from: cfg.from, to, subject, text }) + CRLF + '.' + CRLF);
+ await expect(250);
+ session.write('QUIT');
+ socket.end();
+ return { channel: 'email', ok: true, detail: `已投递至 ${to.join(', ')}` };
+}
+
+/* ---------- MeoW ---------- */
+
+export async function sendMeoW(cfg, { title, text }, { timeoutMs = 12000, fetchImpl = fetch } = {}) {
+ const nickname = cfg.nickname;
+ if (!nickname) throw new Error('MeoW 通道缺少 nickname');
+ if (nickname.includes('/')) throw new Error('MeoW 昵称不允许包含斜杠');
+ const base = (cfg.baseUrl || 'https://api.chuckfang.com').replace(/\/+$/, '');
+ const params = new URLSearchParams();
+ if (cfg.msgType && cfg.msgType !== 'text') {
+ params.set('msgType', cfg.msgType);
+ if (cfg.htmlHeight) params.set('htmlHeight', String(cfg.htmlHeight));
+ }
+ if (cfg.url) params.set('url', cfg.url);
+ const query = params.toString() ? `?${params}` : '';
+ const res = await fetchImpl(`${base}/${encodeURIComponent(nickname)}${query}`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ title, msg: text }),
+ signal: AbortSignal.timeout(timeoutMs),
+ });
+ let payload = null;
+ try {
+ payload = await res.json();
+ } catch {
+ throw new Error(`MeoW 响应不是 JSON(HTTP ${res.status})`);
+ }
+ // 注意:HTTP 状态恒为 200,必须以响应体的 status 为准
+ const status = Number(payload?.status);
+ if (status !== 200) {
+ const reason = payload?.message || payload?.msg || '未知错误';
+ const hint = status === 429 ? '(触发频率限制:3 秒 1 条 / 15 每分钟 / 60 每小时)'
+ : status === 404 ? '(昵称未注册)'
+ : status === 403 ? '(内容被策略拦截)' : '';
+ throw new Error(`MeoW 推送失败 status=${status} ${reason}${hint}`);
+ }
+ return { channel: 'meow', ok: true, detail: payload.message || payload.msg || '推送成功' };
+}
+
+/* ---------- 调度 ---------- */
+
+export function parseQuietHours(spec) {
+ if (!spec) return null;
+ const m = /^(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})$/.exec(String(spec).trim());
+ if (!m) return null;
+ const [from, to] = [Number(m[1]) * 60 + Number(m[2]), Number(m[3]) * 60 + Number(m[4])];
+ return { from, to };
+}
+
+export function inQuietHours(spec, date = new Date()) {
+ const range = parseQuietHours(spec);
+ if (!range) return false;
+ const now = date.getHours() * 60 + date.getMinutes();
+ // 跨零点(如 23:00-07:00)
+ return range.from <= range.to ? now >= range.from && now < range.to : now >= range.from || now < range.to;
+}
+
+/** MeoW 客户端限流:保守遵守 3 秒 1 条 / 每分钟 15 条 / 每小时 60 条 */
+export function createMeoWLimiter(now = () => Date.now()) {
+ let history = [];
+ return {
+ allow() {
+ const t = now();
+ history = history.filter((x) => t - x < 3600_000);
+ const last = history.length ? history.at(-1) : Number.NEGATIVE_INFINITY; // 空历史不能当成 0,否则首条必被拦
+ const inMinute = history.filter((x) => t - x < 60_000).length;
+ if (t - last < 3000 || inMinute >= 15 || history.length >= 60) return false;
+ return true;
+ },
+ mark() {
+ history.push(now());
+ },
+ state: () => history.length,
+ };
+}
+
+const meowLimiter = createMeoWLimiter();
+
+/**
+ * 按配置把一条通知发到所有启用的渠道。
+ * 静默时段内不发送,但返回 suppressed 标记(调用方仍需记录事件)。
+ */
+export async function notifyAll(cfg, message, { logger = console, fetchImpl = fetch, now = () => Date.now() } = {}) {
+ if (!cfg?.enabled) return { suppressed: false, skipped: 'notify-disabled', results: [] };
+ if (inQuietHours(cfg.quietHours)) {
+ logger.log?.(`[notify] 静默时段(${cfg.quietHours}),本条不发送:${message.subject}`);
+ return { suppressed: true, results: [] };
+ }
+ const results = [];
+ const channels = cfg.channels ?? {};
+
+ if (channels.email?.enabled) {
+ try {
+ results.push(await sendEmail(channels.email, message));
+ } catch (err) {
+ results.push({ channel: 'email', ok: false, error: err.message });
+ }
+ }
+ if (channels.meow?.enabled) {
+ if (!meowLimiter.allow()) {
+ results.push({ channel: 'meow', ok: false, error: '本地限流:为避免触发 MeoW 频率限制,本条已跳过', rateLimited: true });
+ } else {
+ try {
+ meowLimiter.mark();
+ results.push(await sendMeoW(channels.meow, message, { fetchImpl }));
+ } catch (err) {
+ results.push({ channel: 'meow', ok: false, error: err.message });
+ }
+ }
+ }
+
+ const failed = results.filter((r) => !r.ok);
+ if (failed.length) logger.error?.(`[notify] 部分渠道发送失败:${failed.map((f) => `${f.channel}: ${f.error}`).join(' | ')}`);
+ if (!failed.length && results.length) logger.log?.(`[notify] 已发送:${message.subject}`);
+ return { suppressed: false, results };
+}
+
+/** 自检:给每个启用的渠道发一条测试消息(不经过静默时段判定) */
+export async function sendTestNotifications(cfg, { logger = console, fetchImpl = fetch } = {}) {
+ const channels = cfg?.channels ?? {};
+ const subject = 'shiroko-monitor 通知自检';
+ const text = `这是一条通道自检消息,收到即表示该渠道配置可用。\n时间:${new Date().toLocaleString('zh-CN')}`;
+ const out = [];
+ if (channels.email?.enabled) {
+ try {
+ out.push(await sendEmail(channels.email, { subject, text }));
+ } catch (err) {
+ out.push({ channel: 'email', ok: false, error: err.message });
+ }
+ } else {
+ out.push({ channel: 'email', ok: false, error: '未启用(settings.notify.channels.email.enabled = false)' });
+ }
+ if (channels.meow?.enabled) {
+ try {
+ out.push(await sendMeoW(channels.meow, { title: subject, text }, { fetchImpl }));
+ } catch (err) {
+ out.push({ channel: 'meow', ok: false, error: err.message });
+ }
+ } else {
+ out.push({ channel: 'meow', ok: false, error: '未启用(settings.notify.channels.meow.enabled = false)' });
+ }
+ for (const r of out) logger.log?.(` ${r.ok ? '✓' : '✗'} ${r.channel}:${r.ok ? r.detail : r.error}`);
+ return out;
+}
diff --git a/prober/node/lib/tls-probe.js b/prober/node/lib/tls-probe.js
new file mode 100644
index 0000000..8ce032d
--- /dev/null
+++ b/prober/node/lib/tls-probe.js
@@ -0,0 +1,62 @@
+/**
+ * TLS 证书探测:只做一次握手取 notAfter,用于「证书还有多久过期」提醒。
+ *
+ * 为什么不复用 HTTP 探测:现有探测走 fetch(undici),拿不到对端证书;
+ * 单独握手更轻,也不影响既有探测路径与判定逻辑。
+ * rejectUnauthorized 设为 false:证书已过期/不受信时也要能读到有效期,
+ * 是否通过校验由 socket.authorized 反映。
+ */
+
+import tls from 'node:tls';
+
+export async function probeCertificate({ host, port = 443, servername, timeoutMs = 8000 }) {
+ if (!host) return { error: '缺少主机名' };
+ return new Promise((resolve) => {
+ let settled = false;
+ const finish = (result) => {
+ if (settled) return;
+ settled = true;
+ try {
+ socket.destroy();
+ } catch { /* 忽略 */ }
+ resolve(result);
+ };
+ const socket = tls.connect(
+ { host, port, servername: servername || host, rejectUnauthorized: false, timeout: timeoutMs },
+ () => {
+ let cert;
+ try {
+ cert = socket.getPeerCertificate();
+ } catch (err) {
+ return finish({ error: `读取证书失败:${err.message}` });
+ }
+ if (!cert || !cert.valid_to) return finish({ error: '未取到证书有效期' });
+ const validTo = new Date(cert.valid_to);
+ if (Number.isNaN(validTo.getTime())) return finish({ error: `证书有效期无法解析:${cert.valid_to}` });
+ finish({
+ error: null,
+ validTo: validTo.toISOString(),
+ daysLeft: Math.floor((validTo.getTime() - Date.now()) / 86_400_000),
+ issuer: cert.issuer?.O || cert.issuer?.CN || null,
+ subject: cert.subject?.CN || null,
+ authorized: socket.authorized === true,
+ });
+ },
+ );
+ socket.on('error', (err) => finish({ error: err.message }));
+ socket.on('timeout', () => finish({ error: `TLS 握手超时(${timeoutMs}ms)` }));
+ });
+}
+
+/** 从监控目标推断是否需要/如何进行证书检查 */
+export function tlsTargetOf(monitor) {
+ if (!monitor || monitor.tls === false) return null;
+ const url = monitor.url;
+ if (!url || !/^https:\/\//i.test(url)) return null;
+ try {
+ const parsed = new URL(url);
+ return { host: parsed.hostname, port: Number(parsed.port) || 443, servername: parsed.hostname };
+ } catch {
+ return null;
+ }
+}
diff --git a/public/assets/app.js b/public/assets/app.js
index 84e8336..10ce17d 100644
--- a/public/assets/app.js
+++ b/public/assets/app.js
@@ -1,4 +1,4 @@
-const VERSION = '0.9.1';
+const VERSION = '0.10.0';
const HB_LIMIT = 48;
const UPTIME_DAYS = 90;
const BROWSER_CONCURRENCY = 6;
@@ -286,7 +286,7 @@ function renderCard(m) {
const head = document.createElement('div');
head.className = 'card-head';
// 名称列在窄屏会被截断,title 保证鼠标悬停仍能看到完整名称
- head.innerHTML = `${escapeHtml(m.name)}
${escapeHtml(typeLabel(m))}`;
+ head.innerHTML = `${escapeHtml(m.name)}
${escapeHtml(typeLabel(m))}${certBadge(st)}${maintBadge(st)}`;
const target = document.createElement('div');
target.className = 'card-target';
@@ -356,6 +356,25 @@ function isExternal(m) {
return m.source === 'uptimerobot';
}
+// 证书徽标:仅在临近到期时出现(≤14 天黄色、≤7 天红色),避免常驻打扰
+function certBadge(st) {
+ const t = st?.tls;
+ if (!t || t.daysLeft == null) return '';
+ if (t.error) return `证书异常`;
+ if (t.daysLeft > 14) return '';
+ const cls = t.daysLeft <= 7 ? 'danger' : 'warn';
+ const tip = `证书到期:${escapeHtml(String(t.validTo ?? '').slice(0, 10))}${t.issuer ? `(${escapeHtml(t.issuer)})` : ''}`;
+ return `证书 ${t.daysLeft} 天`;
+}
+
+// 维护窗口徽标:该时段内不计入可用率统计、不发告警
+function maintBadge(st) {
+ const m = st?.maintenance;
+ if (!m) return '';
+ const tip = `维护窗口${m.to ? `至 ${escapeHtml(String(m.to).replace('T', ' ').slice(0, 16))}` : ''}${m.note ? `:${escapeHtml(m.note)}` : ''}\n期间不计入可用率、不发告警`;
+ return `维护中`;
+}
+
function renderTabs() {
const nav = $('group-tabs');
const monitors = allMonitors();
@@ -469,7 +488,7 @@ function tableRow(m, hide) {
tr.dataset.id = m.id;
const targetCell = hide ? '' : `${escapeHtml(targetText(m))} | `; tr.innerHTML =
`${s.label} | ` +
- `${escapeHtml(m.name)}${escapeHtml(typeLabel(m))} | ` +
+ `${escapeHtml(m.name)}${escapeHtml(typeLabel(m))}${certBadge(st)}${maintBadge(st)} | ` +
targetCell +
`${st?.latency != null ? `${st.latency} ms` : '–'} | ` +
`${escapeHtml(resultText(st, m))} | ` +
@@ -613,6 +632,22 @@ function renderSummary() {
? `服务端数据最后变更:${fmtTime(state.status?.dataUpdatedAt)}(距现在 ${fmtRelative(state.status?.dataUpdatedAt)})\n快照生成:${fmtTime(st?.generatedAt)}\n服务端更新节奏:每 ${serverCadenceSec() ?? '—'} 秒`
: '尚无服务端数据';
$('demo-chip').hidden = !st?.demo;
+ // 通知渠道状态:有失败渠道时给出具体原因(点开 title 看)
+ const notify = st?.notify;
+ const nb = $('notify-badge');
+ if (nb) {
+ const failed = (notify?.lastResult?.results ?? []).filter((r) => !r.ok);
+ if (!notify?.enabled) {
+ nb.hidden = true;
+ } else {
+ nb.hidden = false;
+ nb.classList.toggle('warn-chip', failed.length > 0);
+ nb.textContent = `通知:${(notify.channels ?? []).join(' + ') || '无'}${notify.lastResult ? ` · 最近 ${fmtRelative(notify.lastResult.at)}` : ''}${failed.length ? ` · ${failed.length} 个渠道失败` : ''}`;
+ nb.title = failed.length
+ ? `发送失败:${failed.map((r) => `${r.channel}: ${r.error}`).join('\n')}`
+ : (notify.lastResult ? `最近一次:${notify.lastResult.at},${(notify.lastResult.results ?? []).map((r) => `${r.channel} ${r.ok ? '成功' : '失败'}`).join('、')}` : '尚未发送过通知');
+ }
+ }
const ur = st?.sources?.uptimerobot;
const urChip = $('ur-chip');
if (urChip) {
diff --git a/public/assets/style.css b/public/assets/style.css
index 1208b76..d77b3d6 100644
--- a/public/assets/style.css
+++ b/public/assets/style.css
@@ -412,6 +412,22 @@ tr[data-status="pending"] .t-state { color: var(--pending-text); }
white-space: nowrap;
}
.t-num { font-variant-numeric: tabular-nums; white-space: nowrap; }
+
+/* 证书 / 维护徽标:出现在名称旁,不占独立列 */
+.tls-badge, .maint-badge {
+ margin-left: 8px;
+ padding: 0 6px;
+ border-radius: 999px;
+ font-size: 10px;
+ font-weight: 650;
+ vertical-align: middle;
+ white-space: nowrap;
+ cursor: help;
+}
+.tls-badge.warn { background: var(--pending-bg); color: var(--pending-text); }
+.tls-badge.danger { background: var(--down-bg); color: var(--down-text); }
+.maint-badge { background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent); }
+.card-head .tls-badge, .card-head .maint-badge { flex: none; }
.t-hb svg { display: block; width: 100%; max-width: 150px; height: 24px; }
.t-uptime { white-space: nowrap; }
/* 可用率条随视口伸缩;极窄时只留百分比,避免挤压其他列 */
diff --git a/public/index.html b/public/index.html
index a165c99..6b20ccf 100644
--- a/public/index.html
+++ b/public/index.html
@@ -54,9 +54,10 @@
+
-
+
diff --git a/public/status.json b/public/status.json
index 1a77fbb..ebcc92b 100644
--- a/public/status.json
+++ b/public/status.json
@@ -1,8 +1,8 @@
{
- "prober": "shiroko-monitor-node/0.7.0",
+ "prober": "shiroko-monitor-node/0.10.0",
"demo": false,
- "generatedAt": "2026-10-13T14:36:59.637Z",
- "dataUpdatedAt": "2026-10-13T14:36:43.139Z",
+ "generatedAt": "2026-10-13T15:54:42.793Z",
+ "dataUpdatedAt": "2026-10-13T15:53:59.134Z",
"updateIntervalSec": 300,
"dataFilter": "uptimerobot",
"stats": {
@@ -10,12 +10,12 @@
"up": 5,
"down": 1,
"pending": 0,
- "avgLatency": 874,
+ "avgLatency": 763,
"avgUptime90": 83.3,
"memory": {
- "rssMB": 81.3,
- "heapUsedMB": 13.8,
- "heapTotalMB": 15.7
+ "rssMB": 67.4,
+ "heapUsedMB": 12.7,
+ "heapTotalMB": 14.2
}
},
"sources": {
@@ -23,30 +23,98 @@
"enabled": true,
"ok": true,
"error": null,
- "lastSyncAt": "2026-10-13T14:36:16.338Z",
+ "lastSyncAt": "2026-10-13T15:51:29.877Z",
"count": 6,
"syncing": false,
- "lastSyncMs": 1791902174992
+ "lastSyncMs": 1791906687760
}
},
+ "notify": {
+ "enabled": false,
+ "channels": [],
+ "quietHours": null,
+ "failureThreshold": 2,
+ "lastResult": {
+ "at": "2026-10-13T15:53:51.149Z",
+ "suppressed": false,
+ "skipped": "notify-disabled",
+ "results": []
+ }
+ },
+ "events": [
+ {
+ "t": "2026-10-13T15:53:51.142Z",
+ "id": "cloudflare-site",
+ "name": "Cloudflare 官网",
+ "type": "up",
+ "detail": "已恢复",
+ "maintenance": false
+ },
+ {
+ "t": "2026-10-13T15:52:51.476Z",
+ "id": "cloudflare-site",
+ "name": "Cloudflare 官网",
+ "type": "down",
+ "detail": "ETIMEDOUT",
+ "maintenance": false
+ },
+ {
+ "t": "2026-10-13T15:32:38.735Z",
+ "id": "google-dns-tcp",
+ "name": "Google DNS 53 端口",
+ "type": "down",
+ "detail": "TCP 连接超时",
+ "maintenance": false
+ },
+ {
+ "t": "2026-10-13T15:32:38.735Z",
+ "id": "cloudflare-dns-tcp",
+ "name": "Cloudflare 443 端口",
+ "type": "down",
+ "detail": "TCP 连接超时",
+ "maintenance": false
+ },
+ {
+ "t": "2026-10-13T15:32:34.170Z",
+ "id": "badssl-expired",
+ "name": "BadSSL 过期证书(演示离线)",
+ "type": "down",
+ "detail": "CERT_HAS_EXPIRED",
+ "maintenance": false
+ },
+ {
+ "t": "2026-10-13T15:32:34.129Z",
+ "id": "badssl-expired",
+ "name": "BadSSL 过期证书(演示离线)",
+ "type": "tls",
+ "detail": "证书剩余 -4202 天(阈值 3 天)",
+ "maintenance": false
+ },
+ {
+ "t": "2026-10-13T15:32:34.129Z",
+ "id": "badssl-expired",
+ "name": "BadSSL 过期证书(演示离线)",
+ "type": "tls",
+ "detail": "证书剩余 -4202 天(阈值 7 天)",
+ "maintenance": false
+ },
+ {
+ "t": "2026-10-13T15:32:34.129Z",
+ "id": "badssl-expired",
+ "name": "BadSSL 过期证书(演示离线)",
+ "type": "tls",
+ "detail": "证书剩余 -4202 天(阈值 14 天)",
+ "maintenance": false
+ }
+ ],
"monitors": {
"ur-792059439": {
"status": "up",
- "latency": 464,
+ "latency": 233,
"code": null,
"detail": "UptimeRobot:运行正常",
- "lastCheck": "2026-10-13T14:34:00.000Z",
+ "lastCheck": "2026-10-13T15:50:00.000Z",
"history": [
- {
- "t": "2026-10-12T15:45:00.000Z",
- "ok": true,
- "ms": 392
- },
- {
- "t": "2026-10-12T16:25:00.000Z",
- "ok": true,
- "ms": 288
- },
{
"t": "2026-10-12T17:05:00.000Z",
"ok": true,
@@ -203,20 +271,30 @@
"ms": 271
},
{
- "t": "2026-10-13T13:53:00.000Z",
+ "t": "2026-10-13T13:55:00.000Z",
"ok": true,
- "ms": 294
+ "ms": 307
},
{
"t": "2026-10-13T14:34:00.000Z",
"ok": true,
- "ms": 464
+ "ms": 463
+ },
+ {
+ "t": "2026-10-13T15:10:00.000Z",
+ "ok": true,
+ "ms": 260
+ },
+ {
+ "t": "2026-10-13T15:50:00.000Z",
+ "ok": true,
+ "ms": 233
}
],
"daily": {
"2026-10-13": {
- "total": 46,
- "up": 46
+ "total": 6,
+ "up": 6
}
},
"meta": {
@@ -243,186 +321,191 @@
},
"ur-795497022": {
"status": "up",
- "latency": 704,
+ "latency": 772,
"code": null,
"detail": "UptimeRobot:运行正常",
- "lastCheck": "2026-10-13T14:33:00.000Z",
+ "lastCheck": "2026-10-13T15:48:00.000Z",
"history": [
{
- "t": "2026-10-12T15:40:00.000Z",
- "ok": true,
- "ms": 798
- },
- {
- "t": "2026-10-12T16:20:00.000Z",
- "ok": true,
- "ms": 685
- },
- {
- "t": "2026-10-12T17:00:00.000Z",
+ "t": "2026-10-12T16:35:00.000Z",
"ok": true,
"ms": 700
},
{
- "t": "2026-10-12T17:40:00.000Z",
+ "t": "2026-10-12T17:15:00.000Z",
"ok": true,
- "ms": 694
+ "ms": 689
},
{
- "t": "2026-10-12T18:20:00.000Z",
+ "t": "2026-10-12T17:55:00.000Z",
"ok": true,
- "ms": 703
+ "ms": 725
},
{
- "t": "2026-10-12T19:00:00.000Z",
+ "t": "2026-10-12T18:35:00.000Z",
"ok": true,
- "ms": 729
+ "ms": 744
},
{
- "t": "2026-10-12T19:40:00.000Z",
+ "t": "2026-10-12T19:15:00.000Z",
"ok": true,
- "ms": 713
+ "ms": 723
},
{
- "t": "2026-10-12T20:20:00.000Z",
+ "t": "2026-10-12T19:55:00.000Z",
"ok": true,
- "ms": 784
+ "ms": 698
},
{
- "t": "2026-10-12T21:00:00.000Z",
+ "t": "2026-10-12T20:35:00.000Z",
"ok": true,
- "ms": 628
+ "ms": 768
},
{
- "t": "2026-10-12T21:40:00.000Z",
+ "t": "2026-10-12T21:15:00.000Z",
"ok": true,
- "ms": 629
+ "ms": 616
},
{
- "t": "2026-10-12T22:20:00.000Z",
+ "t": "2026-10-12T21:55:00.000Z",
"ok": true,
- "ms": 751
+ "ms": 626
},
{
- "t": "2026-10-12T23:00:00.000Z",
+ "t": "2026-10-12T22:35:00.000Z",
+ "ok": true,
+ "ms": 674
+ },
+ {
+ "t": "2026-10-12T23:15:00.000Z",
"ok": true,
"ms": 624
},
{
- "t": "2026-10-12T23:40:00.000Z",
+ "t": "2026-10-12T23:55:00.000Z",
"ok": true,
- "ms": 715
+ "ms": 640
},
{
- "t": "2026-10-13T00:20:00.000Z",
+ "t": "2026-10-13T00:35:00.000Z",
"ok": true,
- "ms": 683
+ "ms": 673
},
{
- "t": "2026-10-13T01:00:00.000Z",
- "ok": true,
- "ms": 643
- },
- {
- "t": "2026-10-13T01:40:00.000Z",
- "ok": true,
- "ms": 641
- },
- {
- "t": "2026-10-13T02:20:00.000Z",
+ "t": "2026-10-13T01:15:00.000Z",
"ok": true,
"ms": 609
},
{
- "t": "2026-10-13T03:00:00.000Z",
+ "t": "2026-10-13T01:55:00.000Z",
"ok": true,
- "ms": 606
+ "ms": 759
},
{
- "t": "2026-10-13T03:40:00.000Z",
+ "t": "2026-10-13T02:35:00.000Z",
"ok": true,
- "ms": 720
+ "ms": 581
},
{
- "t": "2026-10-13T04:20:00.000Z",
+ "t": "2026-10-13T03:15:00.000Z",
"ok": true,
- "ms": 711
+ "ms": 666
},
{
- "t": "2026-10-13T05:00:00.000Z",
+ "t": "2026-10-13T03:55:00.000Z",
"ok": true,
- "ms": 598
+ "ms": 662
},
{
- "t": "2026-10-13T05:40:00.000Z",
+ "t": "2026-10-13T04:35:00.000Z",
"ok": true,
- "ms": 729
+ "ms": 685
},
{
- "t": "2026-10-13T06:20:00.000Z",
+ "t": "2026-10-13T05:15:00.000Z",
"ok": true,
- "ms": 676
+ "ms": 647
},
{
- "t": "2026-10-13T07:00:00.000Z",
+ "t": "2026-10-13T05:55:00.000Z",
"ok": true,
- "ms": 691
+ "ms": 588
},
{
- "t": "2026-10-13T07:40:00.000Z",
+ "t": "2026-10-13T06:40:00.000Z",
"ok": true,
- "ms": 696
+ "ms": 625
},
{
- "t": "2026-10-13T08:20:00.000Z",
+ "t": "2026-10-13T07:20:00.000Z",
"ok": true,
- "ms": 677
+ "ms": 673
},
{
- "t": "2026-10-13T09:00:00.000Z",
+ "t": "2026-10-13T08:00:00.000Z",
"ok": true,
- "ms": 969
+ "ms": 758
},
{
- "t": "2026-10-13T09:40:00.000Z",
+ "t": "2026-10-13T08:40:00.000Z",
"ok": true,
- "ms": 669
+ "ms": 826
},
{
- "t": "2026-10-13T10:20:00.000Z",
+ "t": "2026-10-13T09:20:00.000Z",
"ok": true,
- "ms": 840
+ "ms": 749
},
{
- "t": "2026-10-13T11:00:00.000Z",
+ "t": "2026-10-13T10:00:00.000Z",
"ok": true,
- "ms": 735
+ "ms": 791
},
{
- "t": "2026-10-13T11:40:00.000Z",
+ "t": "2026-10-13T10:40:00.000Z",
"ok": true,
- "ms": 693
+ "ms": 678
},
{
- "t": "2026-10-13T12:20:00.000Z",
+ "t": "2026-10-13T11:20:00.000Z",
"ok": true,
- "ms": 793
+ "ms": 740
},
{
- "t": "2026-10-13T13:56:00.000Z",
+ "t": "2026-10-13T12:00:00.000Z",
"ok": true,
- "ms": 1056
+ "ms": 828
},
{
- "t": "2026-10-13T14:33:00.000Z",
+ "t": "2026-10-13T12:40:00.000Z",
"ok": true,
- "ms": 704
+ "ms": 722
+ },
+ {
+ "t": "2026-10-13T13:20:00.000Z",
+ "ok": true,
+ "ms": 849
+ },
+ {
+ "t": "2026-10-13T14:43:00.000Z",
+ "ok": true,
+ "ms": 845
+ },
+ {
+ "t": "2026-10-13T15:15:00.000Z",
+ "ok": true,
+ "ms": 857
+ },
+ {
+ "t": "2026-10-13T15:48:00.000Z",
+ "ok": true,
+ "ms": 772
}
],
"daily": {
"2026-10-13": {
- "total": 46,
- "up": 46
+ "total": 6,
+ "up": 6
}
},
"meta": {
@@ -449,21 +532,11 @@
},
"ur-795556828": {
"status": "up",
- "latency": 338,
+ "latency": 230,
"code": null,
"detail": "UptimeRobot:运行正常",
- "lastCheck": "2026-10-13T14:34:00.000Z",
+ "lastCheck": "2026-10-13T15:50:00.000Z",
"history": [
- {
- "t": "2026-10-12T15:35:00.000Z",
- "ok": true,
- "ms": 275
- },
- {
- "t": "2026-10-12T16:15:00.000Z",
- "ok": true,
- "ms": 450
- },
{
"t": "2026-10-12T16:55:00.000Z",
"ok": true,
@@ -620,20 +693,30 @@
"ms": 413
},
{
- "t": "2026-10-13T13:54:00.000Z",
+ "t": "2026-10-13T13:55:00.000Z",
"ok": true,
- "ms": 187
+ "ms": 283
},
{
"t": "2026-10-13T14:34:00.000Z",
"ok": true,
- "ms": 338
+ "ms": 333
+ },
+ {
+ "t": "2026-10-13T15:10:00.000Z",
+ "ok": true,
+ "ms": 510
+ },
+ {
+ "t": "2026-10-13T15:50:00.000Z",
+ "ok": true,
+ "ms": 230
}
],
"daily": {
"2026-10-13": {
- "total": 46,
- "up": 46
+ "total": 6,
+ "up": 6
}
},
"meta": {
@@ -660,21 +743,11 @@
},
"ur-792059414": {
"status": "up",
- "latency": 2424,
+ "latency": 2238,
"code": null,
"detail": "UptimeRobot:运行正常",
- "lastCheck": "2026-10-13T14:34:00.000Z",
+ "lastCheck": "2026-10-13T15:48:00.000Z",
"history": [
- {
- "t": "2026-10-12T15:40:00.000Z",
- "ok": true,
- "ms": 2901
- },
- {
- "t": "2026-10-12T16:25:00.000Z",
- "ok": true,
- "ms": 2402
- },
{
"t": "2026-10-12T17:10:00.000Z",
"ok": true,
@@ -796,45 +869,50 @@
"ms": 1597
},
{
- "t": "2026-10-13T10:35:00.000Z",
+ "t": "2026-10-13T10:40:00.000Z",
"ok": true,
- "ms": 3015
+ "ms": 2816
},
{
- "t": "2026-10-13T11:15:00.000Z",
+ "t": "2026-10-13T11:25:00.000Z",
"ok": true,
- "ms": 1965
+ "ms": 1912
},
{
- "t": "2026-10-13T11:55:00.000Z",
+ "t": "2026-10-13T12:10:00.000Z",
"ok": true,
- "ms": 2118
+ "ms": 2326
},
{
- "t": "2026-10-13T12:35:00.000Z",
+ "t": "2026-10-13T12:55:00.000Z",
"ok": true,
- "ms": 2557
+ "ms": 2938
},
{
- "t": "2026-10-13T13:15:00.000Z",
+ "t": "2026-10-13T13:40:00.000Z",
"ok": true,
- "ms": 1671
+ "ms": 3146
},
{
- "t": "2026-10-13T13:55:00.000Z",
+ "t": "2026-10-13T14:25:00.000Z",
"ok": true,
- "ms": 1908
+ "ms": 2125
},
{
- "t": "2026-10-13T14:34:00.000Z",
+ "t": "2026-10-13T15:08:00.000Z",
"ok": true,
- "ms": 2424
+ "ms": 2892
+ },
+ {
+ "t": "2026-10-13T15:48:00.000Z",
+ "ok": true,
+ "ms": 2238
}
],
"daily": {
"2026-10-13": {
- "total": 46,
- "up": 46
+ "total": 6,
+ "up": 6
}
},
"meta": {
@@ -861,191 +939,191 @@
},
"ur-798788113": {
"status": "up",
- "latency": 438,
+ "latency": 340,
"code": null,
"detail": "UptimeRobot:运行正常",
- "lastCheck": "2026-10-13T14:35:00.000Z",
+ "lastCheck": "2026-10-13T15:46:00.000Z",
"history": [
{
- "t": "2026-10-12T15:45:00.000Z",
+ "t": "2026-10-12T16:50:00.000Z",
"ok": true,
- "ms": 462
+ "ms": 463
},
{
- "t": "2026-10-12T16:25:00.000Z",
+ "t": "2026-10-12T17:30:00.000Z",
"ok": true,
- "ms": 288
+ "ms": 320
},
{
- "t": "2026-10-12T17:05:00.000Z",
+ "t": "2026-10-12T18:10:00.000Z",
"ok": true,
- "ms": 409
+ "ms": 404
},
{
- "t": "2026-10-12T17:50:00.000Z",
- "ok": true,
- "ms": 312
- },
- {
- "t": "2026-10-12T18:30:00.000Z",
- "ok": true,
- "ms": 472
- },
- {
- "t": "2026-10-12T19:10:00.000Z",
- "ok": true,
- "ms": 421
- },
- {
- "t": "2026-10-12T19:50:00.000Z",
- "ok": true,
- "ms": 332
- },
- {
- "t": "2026-10-12T20:30:00.000Z",
- "ok": true,
- "ms": 310
- },
- {
- "t": "2026-10-12T21:10:00.000Z",
- "ok": true,
- "ms": 436
- },
- {
- "t": "2026-10-12T21:50:00.000Z",
- "ok": true,
- "ms": 338
- },
- {
- "t": "2026-10-12T22:30:00.000Z",
- "ok": true,
- "ms": 423
- },
- {
- "t": "2026-10-12T23:10:00.000Z",
- "ok": true,
- "ms": 247
- },
- {
- "t": "2026-10-12T23:50:00.000Z",
- "ok": true,
- "ms": 296
- },
- {
- "t": "2026-10-13T00:30:00.000Z",
- "ok": true,
- "ms": 239
- },
- {
- "t": "2026-10-13T01:10:00.000Z",
- "ok": true,
- "ms": 393
- },
- {
- "t": "2026-10-13T01:50:00.000Z",
- "ok": true,
- "ms": 346
- },
- {
- "t": "2026-10-13T02:30:00.000Z",
- "ok": true,
- "ms": 441
- },
- {
- "t": "2026-10-13T03:10:00.000Z",
- "ok": true,
- "ms": 443
- },
- {
- "t": "2026-10-13T03:50:00.000Z",
- "ok": true,
- "ms": 382
- },
- {
- "t": "2026-10-13T04:30:00.000Z",
- "ok": true,
- "ms": 355
- },
- {
- "t": "2026-10-13T05:10:00.000Z",
- "ok": true,
- "ms": 414
- },
- {
- "t": "2026-10-13T05:50:00.000Z",
+ "t": "2026-10-12T18:50:00.000Z",
"ok": true,
"ms": 343
},
{
- "t": "2026-10-13T06:30:00.000Z",
+ "t": "2026-10-12T19:30:00.000Z",
"ok": true,
- "ms": 434
+ "ms": 476
},
{
- "t": "2026-10-13T07:10:00.000Z",
+ "t": "2026-10-12T20:10:00.000Z",
"ok": true,
- "ms": 444
+ "ms": 285
},
{
- "t": "2026-10-13T07:50:00.000Z",
+ "t": "2026-10-12T20:50:00.000Z",
"ok": true,
- "ms": 281
+ "ms": 449
},
{
- "t": "2026-10-13T08:30:00.000Z",
+ "t": "2026-10-12T21:30:00.000Z",
"ok": true,
- "ms": 453
+ "ms": 332
},
{
- "t": "2026-10-13T09:15:00.000Z",
+ "t": "2026-10-12T22:10:00.000Z",
"ok": true,
- "ms": 333
+ "ms": 450
},
{
- "t": "2026-10-13T09:55:00.000Z",
+ "t": "2026-10-12T22:50:00.000Z",
"ok": true,
- "ms": 602
+ "ms": 231
},
{
- "t": "2026-10-13T10:35:00.000Z",
+ "t": "2026-10-12T23:30:00.000Z",
"ok": true,
- "ms": 519
+ "ms": 249
},
{
- "t": "2026-10-13T11:15:00.000Z",
+ "t": "2026-10-13T00:10:00.000Z",
"ok": true,
- "ms": 325
+ "ms": 286
},
{
- "t": "2026-10-13T11:55:00.000Z",
+ "t": "2026-10-13T00:50:00.000Z",
"ok": true,
- "ms": 933
+ "ms": 223
},
{
- "t": "2026-10-13T12:35:00.000Z",
+ "t": "2026-10-13T01:30:00.000Z",
"ok": true,
- "ms": 424
+ "ms": 386
},
{
- "t": "2026-10-13T13:15:00.000Z",
+ "t": "2026-10-13T02:10:00.000Z",
"ok": true,
- "ms": 460
+ "ms": 511
},
{
- "t": "2026-10-13T13:55:00.000Z",
+ "t": "2026-10-13T02:50:00.000Z",
"ok": true,
- "ms": 328
+ "ms": 292
},
{
- "t": "2026-10-13T14:35:00.000Z",
+ "t": "2026-10-13T03:30:00.000Z",
"ok": true,
- "ms": 438
+ "ms": 435
+ },
+ {
+ "t": "2026-10-13T04:15:00.000Z",
+ "ok": true,
+ "ms": 400
+ },
+ {
+ "t": "2026-10-13T04:55:00.000Z",
+ "ok": true,
+ "ms": 462
+ },
+ {
+ "t": "2026-10-13T05:35:00.000Z",
+ "ok": true,
+ "ms": 377
+ },
+ {
+ "t": "2026-10-13T06:15:00.000Z",
+ "ok": true,
+ "ms": 396
+ },
+ {
+ "t": "2026-10-13T06:55:00.000Z",
+ "ok": true,
+ "ms": 310
+ },
+ {
+ "t": "2026-10-13T07:35:00.000Z",
+ "ok": true,
+ "ms": 336
+ },
+ {
+ "t": "2026-10-13T08:15:00.000Z",
+ "ok": true,
+ "ms": 379
+ },
+ {
+ "t": "2026-10-13T08:55:00.000Z",
+ "ok": true,
+ "ms": 476
+ },
+ {
+ "t": "2026-10-13T09:35:00.000Z",
+ "ok": true,
+ "ms": 605
+ },
+ {
+ "t": "2026-10-13T10:15:00.000Z",
+ "ok": true,
+ "ms": 456
+ },
+ {
+ "t": "2026-10-13T10:55:00.000Z",
+ "ok": true,
+ "ms": 420
+ },
+ {
+ "t": "2026-10-13T11:35:00.000Z",
+ "ok": true,
+ "ms": 878
+ },
+ {
+ "t": "2026-10-13T12:15:00.000Z",
+ "ok": true,
+ "ms": 530
+ },
+ {
+ "t": "2026-10-13T12:55:00.000Z",
+ "ok": true,
+ "ms": 517
+ },
+ {
+ "t": "2026-10-13T13:35:00.000Z",
+ "ok": true,
+ "ms": 491
+ },
+ {
+ "t": "2026-10-13T14:20:00.000Z",
+ "ok": true,
+ "ms": 429
+ },
+ {
+ "t": "2026-10-13T15:06:00.000Z",
+ "ok": true,
+ "ms": 443
+ },
+ {
+ "t": "2026-10-13T15:46:00.000Z",
+ "ok": true,
+ "ms": 340
}
],
"daily": {
"2026-10-13": {
- "total": 46,
- "up": 46
+ "total": 6,
+ "up": 6
}
},
"meta": {
@@ -1085,7 +1163,7 @@
],
"daily": {
"2026-10-13": {
- "total": 46,
+ "total": 6,
"up": 0
}
},
diff --git a/scripts/verify-notify.cjs b/scripts/verify-notify.cjs
new file mode 100644
index 0000000..7286094
--- /dev/null
+++ b/scripts/verify-notify.cjs
@@ -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 .');
+ 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);
+});