feat: P0 通知(邮件 + MeoW)与 TLS 证书监控,事件时间线与维护窗口

通知(P0-1):
- 邮件:自写 SMTP 客户端,零依赖。465 隐式 TLS / 587·25 STARTTLS、
  AUTH LOGIN+PLAIN、RFC2047 中文主题、base64 正文、多收件人;
  服务器不支持 STARTTLS 时默认拒绝明文(需显式 allowInsecure)
- MeoW:按官方文档实现,昵称寻址、msgType/htmlHeight 走查询参数、
  以响应体 status 判定成功(HTTP 恒 200)、429/404/403 可读化、
  本地限流器遵守 3s/15min/60h
- 告警:连续 N 次失败去抖 + 恢复通知 + 同轮合并 + 去抖状态持久化
  (data/notify-state.json,gitignore)+ 静默时段(跨零点)
- --test-notify 自检命令、--data-dir / SHIROKO_DATA_DIR

证书(P0-2):
- 独立 TLS 握手取 notAfter(不动既有 fetch 探测路径),默认 6 小时一次
- 面板徽标 ≤14 天黄 / ≤7 天红,阈值 14/7/3 各告一次、已过期只报最严重一档
- 单个目标可用 tls: false 关闭

事件与维护(P1-1 的后端部分):
- data/events.json 追加式事件流(最近 500 条)+ GET /api/events +
  快照内附最近 20 条;redactDetails 时脱敏
- 维护窗口:窗口内不计入可用率、不发告警、显示「维护中」徽标

测试抓到并修复的三个真 bug:
1. SMTP 空闲超时无人处理 → 服务器不响应会把探针循环挂死
2. MeoW 限流器把空历史当 0 → 第一条通知永远被自己拦掉
3. 去抖状态在无事轮次不落盘 → 连续失败计数丢失、永远达不到阈值

验证:新增 verify-notify.cjs 39 项(假 SMTP / 假 MeoW / 三轮集成 / 维护窗口 /
真实证书握手),六套合计 154 项全通过。
This commit is contained in:
tamakyi
2026-10-13 23:54:46 +08:00
parent ea846dee31
commit bdaa31f10c
17 changed files with 1545 additions and 291 deletions
+269 -3
View File
@@ -20,6 +20,8 @@ import { probeHttp } from './lib/http-probe.js';
import { probeTcp } from './lib/tcp-probe.js';
import { probeBanner } from './lib/banner-probe.js';
import { fetchUptimeRobotMonitors } from './lib/uptimerobot.js';
import { notifyAll, sendTestNotifications, createMeoWLimiter } from './lib/notify.js';
import { probeCertificate, tlsTargetOf } from './lib/tls-probe.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '../..');
@@ -35,13 +37,15 @@ const CONCURRENCY = 8;
const WRITE_DEBOUNCE_MS = 300;
const DEFAULT_INTERVAL_SEC = 60;
const PORT = Number(process.env.PORT || 8787);
const VERSION = '0.9.1';
const VERSION = '0.10.0';
const UR_DEFAULT_POLL_SEC = 300;
const UR_MIN_REFRESH_GAP_MS = 60000;
const argv = process.argv.slice(2);
const ONCE = argv.includes('--once');
const DEMO = argv.includes('--demo');
// --test-notify:只给各通知渠道发一条自检消息后退出(不探测、不写快照)
const TEST_NOTIFY = argv.includes('--test-notify');
// --out=<path> 或 --out <path>:指定快照输出路径(默认 public/status.json)
function argValue(name) {
@@ -54,6 +58,10 @@ const OUT_PATH = path.resolve(argValue('--out') || STATUS_PATH);
// --config / --local-config:多实例或测试时指定配置文件(默认 public/monitors.json 与仓库根的私有覆盖)
const CONFIG_OVERRIDE = argValue('--config');
const LOCAL_CONFIG_OVERRIDE = argValue('--local-config');
// --data-dir=<path>:通知状态与事件时间线的存放目录(默认 ./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, () => {
+324
View File
@@ -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;
}
+62
View File
@@ -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;
}
}