/** * SSL Certificate Monitor * Periodically checks SSL certificates on services with HTTPS URLs. * Alerts at 30, 14, and 7 days before expiry. * * @module ssl-monitor */ const tls = require('tls'); const EventEmitter = require('events'); const path = require('path'); const { readJsonFile, writeJsonFile } = require('./fs-helpers'); const { resolveServiceUrl } = require('./url-resolver'); /** Default check interval: 1 hour */ const DEFAULT_INTERVAL_MS = 3600000; /** Alert thresholds in days */ const THRESHOLDS = { WARNING: 30, URGENT: 14, CRITICAL: 7 }; /** TLS connection timeout in milliseconds */ const TLS_TIMEOUT_MS = 10000; class SSLMonitor extends EventEmitter { /** * Create an SSLMonitor instance. * @param {Object} ctx - Shared application context * @param {Object} ctx.servicesStateManager - State manager for reading services * @param {Function} ctx.buildServiceUrl - URL builder helper * @param {Object} ctx.siteConfig - Site configuration * @param {Object} ctx.notification - NotificationManager instance * @param {Object} ctx.log - Logger instance * @param {string} [ctx.SSL_CACHE_FILE] - Path to persist SSL cache */ constructor(ctx) { super(); this.ctx = ctx; this.log = ctx.log || console; /** @type {Map} hostname → last cert check result */ this.certStatus = new Map(); /** @type {Map} hostname → last notified threshold level */ this.notifiedThresholds = new Map(); /** @type {Map} hostname → service ID mapping */ this.hostnameToServiceId = new Map(); /** @type {NodeJS.Timeout|null} */ this.intervalHandle = null; /** Current config */ this.config = { enabled: true, intervalMs: DEFAULT_INTERVAL_MS }; /** Cache file path */ this.cacheFile = ctx.SSL_CACHE_FILE || path.join(path.dirname(ctx.SERVICES_FILE || './data'), 'ssl-cache.json'); } /** * Check the SSL certificate for a given hostname and port. * Connects via TLS with rejectUnauthorized: false to retrieve certificate info. * * @param {string} hostname - The hostname to check * @param {number} [port=443] - The port to connect to * @returns {Promise} Certificate information */ async checkCert(hostname, port = 443) { return new Promise((resolve, reject) => { const socket = tls.connect({ host: hostname, port, rejectUnauthorized: false, servername: hostname, timeout: TLS_TIMEOUT_MS }, () => { try { const cert = socket.getPeerCertificate(); if (!cert || Object.keys(cert).length === 0) { socket.destroy(); return reject(new Error(`No certificate returned for ${hostname}:${port}`)); } const validFrom = new Date(cert.valid_from); const validTo = new Date(cert.valid_to); const now = new Date(); const msRemaining = validTo.getTime() - now.getTime(); const daysRemaining = Math.ceil(msRemaining / (1000 * 60 * 60 * 24)); const result = { hostname, port, subject: cert.subject?.CN || cert.subject?.O || 'Unknown', issuer: cert.issuer?.CN || cert.issuer?.O || 'Unknown', validFrom: cert.valid_from, validTo: cert.valid_to, daysRemaining, fingerprint: cert.fingerprint || null, isExpiring: daysRemaining <= THRESHOLDS.WARNING, checkedAt: new Date().toISOString() }; socket.destroy(); resolve(result); } catch (err) { socket.destroy(); reject(err); } }); socket.on('error', (err) => { reject(new Error(`TLS connect error for ${hostname}:${port}: ${err.message}`)); }); socket.setTimeout(TLS_TIMEOUT_MS, () => { socket.destroy(new Error(`TLS connection timeout for ${hostname}:${port}`)); reject(new Error(`TLS connection timeout for ${hostname}:${port}`)); }); }); } /** * Check SSL certificates for all services that have HTTPS URLs. * Reads services from ctx.servicesStateManager, resolves URLs, and checks each HTTPS cert. * * @returns {Promise} Map of hostname → cert status */ async checkAll() { if (!this.config.enabled) { this.log.info('ssl-monitor', 'SSL monitoring is disabled, skipping check'); return this.getStatus(); } let servicesData; try { servicesData = await this.ctx.servicesStateManager.read(); } catch (err) { this.log.error('ssl-monitor', 'Failed to read services', { error: err.message }); return this.getStatus(); } const services = Array.isArray(servicesData) ? servicesData : (servicesData.services || []); for (const service of services) { const serviceId = service.id || service.name?.toLowerCase(); if (!serviceId) continue; try { const url = resolveServiceUrl(serviceId, service, this.ctx.siteConfig, this.ctx.buildServiceUrl); if (!url) continue; const parsed = new URL(url); if (parsed.protocol !== 'https:') continue; const hostname = parsed.hostname; const port = parseInt(parsed.port) || 443; // Map hostname back to service ID this.hostnameToServiceId.set(hostname, serviceId); const result = await this.checkCert(hostname, port); // Store result this.certStatus.set(hostname, result); // Emit check event this.emit('cert-check', { serviceId, hostname, result }); // Check alert thresholds await this._checkAndNotify(hostname, result, serviceId); } catch (err) { this.log.warn('ssl-monitor', `Failed to check cert for service ${serviceId}`, { error: err.message }); } } // Persist results await this._saveCache(); return this.getStatus(); } /** * Start periodic SSL certificate checking. * * @param {number} [intervalMs=3600000] - Check interval in milliseconds */ start(intervalMs) { if (intervalMs !== undefined) { this.config.intervalMs = intervalMs; } if (this.intervalHandle) { this.log.warn('ssl-monitor', 'SSL monitor is already running'); return; } this.config.enabled = true; // Load cached data this._loadCache().catch(err => { this.log.warn('ssl-monitor', 'Failed to load SSL cache', { error: err.message }); }); // Initial check (non-blocking) this.checkAll().catch(err => { this.log.error('ssl-monitor', 'Initial SSL check failed', { error: err.message }); }); // Schedule periodic checks this.intervalHandle = setInterval(() => { this.checkAll().catch(err => { this.log.error('ssl-monitor', 'Periodic SSL check failed', { error: err.message }); }); }, this.config.intervalMs); this.log.info('ssl-monitor', 'SSL monitoring started', { intervalMs: this.config.intervalMs }); } /** * Stop periodic SSL certificate checking. */ stop() { if (this.intervalHandle) { clearInterval(this.intervalHandle); this.intervalHandle = null; } this.config.enabled = false; this.log.info('ssl-monitor', 'SSL monitoring stopped'); } /** * Get the current SSL certificate status for all checked hostnames. * * @returns {Object} Map of hostname → cert status */ getStatus() { const status = {}; for (const [hostname, cert] of this.certStatus.entries()) { status[hostname] = { ...cert }; } return status; } /** * Get the SSL certificate status for a specific service. * * @param {string} serviceId - The service ID to look up * @returns {Object|null} Certificate status or null if not found */ getServiceCertStatus(serviceId) { // Find hostname mapped to this service for (const [hostname, id] of this.hostnameToServiceId.entries()) { if (id === serviceId) { const cert = this.certStatus.get(hostname); return cert ? { ...cert, serviceId } : null; } } return null; } /** * Get current monitoring configuration. * * @returns {Object} Config with interval and enabled state */ getConfig() { return { ...this.config }; } /** * Update monitoring configuration. * * @param {Object} updates - Config updates * @param {boolean} [updates.enabled] - Enable/disable monitoring * @param {number} [updates.intervalMs] - Check interval in milliseconds */ updateConfig(updates) { if (typeof updates.enabled === 'boolean') { this.config.enabled = updates.enabled; if (!updates.enabled && this.intervalHandle) { this.stop(); } } if (typeof updates.intervalMs === 'number' && updates.intervalMs >= 60000) { this.config.intervalMs = updates.intervalMs; // Restart interval if running if (this.intervalHandle) { clearInterval(this.intervalHandle); this.intervalHandle = setInterval(() => { this.checkAll().catch(err => { this.log.error('ssl-monitor', 'Periodic SSL check failed', { error: err.message }); }); }, this.config.intervalMs); } } } // ===== Private Methods ===== /** * Check alert thresholds and send notifications if thresholds are crossed. * Only sends one notification per threshold per hostname. * * @param {string} hostname * @param {Object} certResult * @param {string} serviceId */ async _checkAndNotify(hostname, certResult, serviceId) { const { daysRemaining } = certResult; const key = hostname; const lastNotified = this.notifiedThresholds.get(key) || Infinity; let level = null; let eventType = null; let message = null; if (daysRemaining <= THRESHOLDS.CRITICAL) { level = THRESHOLDS.CRITICAL; eventType = 'cert-critical'; message = `🔒 CRITICAL: SSL certificate for ${hostname} expires in ${daysRemaining} days!`; } else if (daysRemaining <= THRESHOLDS.URGENT) { level = THRESHOLDS.URGENT; eventType = 'cert-expiring'; message = `⚠️ URGENT: SSL certificate for ${hostname} expires in ${daysRemaining} days`; } else if (daysRemaining <= THRESHOLDS.WARNING) { level = THRESHOLDS.WARNING; eventType = 'cert-expiring'; message = `⚠️ SSL certificate for ${hostname} expires in ${daysRemaining} days`; } if (level !== null && level < lastNotified) { // New threshold crossed — send notification this.notifiedThresholds.set(key, level); this.emit(eventType, { hostname, serviceId, daysRemaining, level }); if (this.ctx.notification) { try { await this.ctx.notification.send('ssl-cert-expiry', { text: message, hostname, serviceId, daysRemaining, level, validTo: certResult.validTo }, level <= THRESHOLDS.CRITICAL ? 'error' : 'warning'); } catch (err) { this.log.error('ssl-monitor', 'Failed to send SSL notification', { error: err.message }); } } } else if (level === null) { // Cert is healthy — reset notification tracking this.notifiedThresholds.delete(key); } } /** * Persist cert status cache to disk. */ async _saveCache() { try { const data = { lastChecked: new Date().toISOString(), certs: {}, hostnameToServiceId: Object.fromEntries(this.hostnameToServiceId) }; for (const [hostname, cert] of this.certStatus.entries()) { data.certs[hostname] = cert; } await writeJsonFile(this.cacheFile, data); } catch (err) { this.log.warn('ssl-monitor', 'Failed to save SSL cache', { error: err.message }); } } /** * Load cert status cache from disk. */ async _loadCache() { try { const data = await readJsonFile(this.cacheFile, null); if (data && data.certs) { for (const [hostname, cert] of Object.entries(data.certs)) { this.certStatus.set(hostname, cert); } if (data.hostnameToServiceId) { for (const [hostname, serviceId] of Object.entries(data.hostnameToServiceId)) { this.hostnameToServiceId.set(hostname, serviceId); } } this.log.info('ssl-monitor', 'Loaded SSL cache', { certCount: this.certStatus.size }); } } catch (err) { this.log.warn('ssl-monitor', 'Failed to load SSL cache', { error: err.message }); } } } module.exports = SSLMonitor;