通知(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 项全通过。
325 lines
12 KiB
JavaScript
325 lines
12 KiB
JavaScript
/**
|
||
* 通知发送:邮件(手写 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;
|
||
}
|