Files
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

63 lines
2.3 KiB
JavaScript
Raw Permalink 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.
/**
* 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;
}
}