← 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); });