Files
shiroko-monitor/prober/node/lib/notify.js
T
tamakyi bdaa31f10c 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 项全通过。
2026-10-13 23:54:46 +08:00

325 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 通知发送:邮件(手写 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;
}