feat: auto-restart policies, SSL monitoring, DNS propagation, dependency tracking, config drift detection
This commit is contained in:
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user