/** * 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; } }