← back to Dw Yolo Loop
scripts/tls-expiry/tls-expiry-canary.mjs
90 lines
// tls-expiry-canary — read-only TLS handshake across the core DW web fleet,
// report days-to-expiry per host, flag any cert expired or expiring soon.
// An expired cert is a hard, total, customer-facing outage (browser interstitial
// → zero conversions) that arrives on a KNOWN clock — the one failure mode where
// early detection has asymmetric payoff. dw-uptime-probe checks reachability but
// a soon-to-expire cert passes uptime right up until the cliff; nothing else in
// the canary fleet checks expiry. This fills that gap.
//
// node tls-expiry-canary.mjs [--warn 21] [--fail 7] [--hosts a.com,b.com]
// READ-ONLY (TLS handshake only, no HTTP body, no writes). $0.
import tls from 'node:tls';
import fs from 'node:fs';
const args = process.argv.slice(2);
const WARN = parseInt(args.find((_,i,a)=>a[i-1]==='--warn') || '21', 10) || 21; // days
const FAIL = parseInt(args.find((_,i,a)=>a[i-1]==='--fail') || '7', 10) || 7; // days
const HOSTS = (args.find((_,i,a)=>a[i-1]==='--hosts') || [
'www.designerwallcoverings.com','designerwallcoverings.com',
'apartmentwallpaper.com','philipperomano.com','novasuede.com','wallco.ai',
'architecturalwallcoverings.com','thesetdecorator.com',
'corkwallcovering.com','silkwallpaper.com','linenwallpaper.com','grasscloth.com',
'raffiawallpaper.com','glitterwallpaper.com','hospitalitywallcoverings.com',
].join(',')).split(',').map(h=>h.trim()).filter(Boolean);
const today = new Date().toISOString().slice(0,10); // dynamic — a scheduled run must not clobber a prior day's report
const OUT = `${process.env.HOME}/.claude/yolo-queue/tls-expiry-${today}.json`;
const MD = `${process.env.HOME}/.claude/yolo-queue/tls-expiry-${today}.md`;
function checkHost(host) {
return new Promise((resolve) => {
const socket = tls.connect({ host, port: 443, servername: host, timeout: 12000, rejectUnauthorized: false }, () => {
const cert = socket.getPeerCertificate();
if (!cert || !cert.valid_to) { socket.end(); return resolve({ host, state: 'UNKNOWN', detail: 'no cert' }); }
const validTo = new Date(cert.valid_to).getTime();
const days = Math.floor((validTo - Date.now()) / 86400000);
const authorized = socket.authorized;
socket.end();
resolve({ host, state: 'OK', days_to_expiry: days, valid_to: cert.valid_to,
issuer: (cert.issuer && (cert.issuer.O || cert.issuer.CN)) || '?', chain_authorized: authorized });
});
socket.on('error', (e) => resolve({ host, state: 'UNKNOWN', detail: String(e.message).slice(0,50) }));
socket.on('timeout', () => { socket.destroy(); resolve({ host, state: 'UNKNOWN', detail: 'timeout' }); });
});
}
// best-effort local CNCP alert — timeout-bounded, NEVER throws (the canary must
// still finish + write its report even if the dashboard is down/slow).
async function alertCNCP(note) {
try { const ac = new AbortController(); const t = setTimeout(()=>ac.abort(), 6000);
const r = await fetch(`${process.env.CNCP_URL||'http://localhost:3333'}/api/parking-lot`,
{ method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ url:'file://tls-expiry-canary', note }), signal: ac.signal });
clearTimeout(t); return r.ok; } catch { return false; }
}
(async () => {
const results = [];
for (const h of HOSTS) results.push(await checkHost(h));
const decided = results.filter(r => r.state === 'OK');
const expired = decided.filter(r => r.days_to_expiry < 0);
const critical = decided.filter(r => r.days_to_expiry >= 0 && r.days_to_expiry < FAIL);
const warning = decided.filter(r => r.days_to_expiry >= FAIL && r.days_to_expiry < WARN);
const unreachable = results.filter(r => r.state === 'UNKNOWN');
const verdict = (expired.length || critical.length) ? 'FAIL' : warning.length ? 'WARN' : 'PASS';
const report = { generated_at: new Date().toISOString(), checked: results.length, warn_at_days: WARN, fail_at_days: FAIL,
verdict, expired: expired.map(r=>({host:r.host,days:r.days_to_expiry})), critical: critical.map(r=>({host:r.host,days:r.days_to_expiry})),
warning: warning.map(r=>({host:r.host,days:r.days_to_expiry})), unreachable: unreachable.map(r=>({host:r.host,detail:r.detail})),
all: results.map(r=>({host:r.host, state:r.state, days:r.days_to_expiry ?? null, valid_to:r.valid_to ?? null, issuer:r.issuer ?? null, chain_ok:r.chain_authorized ?? null, detail:r.detail ?? null })) };
fs.writeFileSync(OUT, JSON.stringify(report, null, 2));
const emoji = verdict==='FAIL'?'🔴':verdict==='WARN'?'🟠':'🟢';
let md = `# TLS cert-expiry canary — ${new Date().toISOString().slice(0,16)}\n\n`;
md += `Checked **${results.length}** fleet hosts. **READ-ONLY (TLS handshake only), $0.** Thresholds: WARN <${WARN}d · FAIL <${FAIL}d or expired.\n\n`;
md += `## ${emoji} ${verdict}\n`;
if (expired.length) md += `🔴 **EXPIRED:** ${expired.map(r=>`${r.host} (${r.days_to_expiry}d)`).join(', ')}\n`;
if (critical.length) md += `🔴 **<${FAIL}d:** ${critical.map(r=>`${r.host} (${r.days_to_expiry}d)`).join(', ')}\n`;
if (warning.length) md += `🟠 **<${WARN}d:** ${warning.map(r=>`${r.host} (${r.days_to_expiry}d)`).join(', ')}\n`;
if (verdict==='PASS') md += `All certs valid ≥${WARN} days. 🟢\n`;
md += `\n| Host | State | Days→expiry | Issuer | Chain | Valid-to |\n|---|---|---:|---|---|---|\n`;
for (const r of report.all) md += `| ${r.host} | ${r.state} | ${r.days ?? '—'} | ${r.issuer ?? '—'} | ${r.chain_ok===null?'—':(r.chain_ok?'ok':'⚠')} | ${r.valid_to ?? r.detail ?? '—'} |\n`;
md += `\n_UNKNOWN = DNS/connect/timeout (host may be parked, behind a different edge, or not on this list's apex). chain ⚠ = self-signed/untrusted chain even if not expired._\n`;
fs.writeFileSync(MD, md);
console.log(`[tls-expiry] ${emoji} ${verdict} · checked=${results.length} · expired=${expired.length} crit=${critical.length} warn=${warning.length} unreachable=${unreachable.length}`);
for (const r of [...expired,...critical,...warning]) console.log(` ⚠️ ${r.host}: ${r.days_to_expiry}d`);
console.log(`Report: ${MD}`);
if (verdict === 'FAIL') await alertCNCP(`[TLS-EXPIRY 🔴 FAIL] ${[...expired,...critical].map(r=>`${r.host}(${r.days_to_expiry}d)`).join(', ')} — cert expired/expiring; renew now`);
process.exit(verdict==='FAIL'?2:0);
})().catch(e => { console.error('FATAL', e.message); process.exit(1); });