通知(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 项全通过。
63 lines
2.3 KiB
JavaScript
63 lines
2.3 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|
||
}
|