412 lines
12 KiB
JavaScript
412 lines
12 KiB
JavaScript
/**
|
|
* 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<string, Object>} hostname → last cert check result */
|
|
this.certStatus = new Map();
|
|
|
|
/** @type {Map<string, number>} hostname → last notified threshold level */
|
|
this.notifiedThresholds = new Map();
|
|
|
|
/** @type {Map<string, string>} 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<Object>} 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<Object>} 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;
|