// ===================================================================== // CERTIFICATS HTTPS DU REVERSE PROXY (13/10/2026, demande du proprietaire) // Le 12/10 a 15:52 UTC, le certificat de n8n.airtria.pro a expire sans que // personne ne le sache (MCP n8n et acces public en echec) ; gitlab et // open-webui le meme jour, openclaw le lendemain. Le DSM du .50 ne sait plus // joindre Let's Encrypt : le certificat est obtenu sur le .17 puis importe a la // main sur le .50, et rien ne relance cette copie. Ce module lit le certificat // REELLEMENT servi par le reverse proxy pour chaque nom (poignee de main TLS, // aucune requete HTTP) ; le service d'historisation publie // sensor.evcs_certificats et SPARK previent par WhatsApp. // `certificatsAAlerter` est pure (tests : tests/certificats/run.mjs). // ===================================================================== import tls from 'node:tls'; export const PROXY_HOTE = '10.10.10.50'; export const PROXY_PORT = 443; // Noms servis par le reverse proxy du .50 (verifie le 13/10 : airtria.fr et // airtria.com n'y sont pas, le proxy renvoie son certificat par defaut). export const DOMAINES = ['n8n.airtria.pro', 'openclaw.airtria.pro', 'gitlab.airtria.pro', 'open-webui.airtria.pro']; // Le .17 renouvelle a ~30 jours de l'echeance ; la copie vers le .50 est // manuelle : 14 jours laissent deux week-ends pour la faire. export const PREAVIS_J = 14; // Lecture du certificat servi (non pure). Ne leve jamais : { domaine, expireMs, // noms, erreur }. export function lireCertificat(domaine, hote = PROXY_HOTE, port = PROXY_PORT, delaiMs = 8000) { return new Promise((resolve) => { let fini = false; const fin = (r) => { if (!fini) { fini = true; resolve({ domaine, ...r }); } }; const s = tls.connect({ host: hote, port, servername: domaine, rejectUnauthorized: false, timeout: delaiMs }, () => { const c = s.getPeerCertificate(); const noms = [c?.subject?.CN, ...String(c?.subjectaltname || '').split(',').map((x) => x.trim().replace(/^DNS:/, ''))] .filter(Boolean); fin({ expireMs: Date.parse(c?.valid_to), noms, erreur: null }); s.end(); }); s.on('error', (e) => fin({ expireMs: null, noms: [], erreur: e.code || e.message })); s.on('timeout', () => { s.destroy(); fin({ expireMs: null, noms: [], erreur: 'delai depasse' }); }); }); } const couvre = (noms, d) => noms.some((n) => n === d || (n.startsWith('*.') && d.endsWith(n.slice(1)) && d.split('.').length === n.split('.').length)); const jour = (ms) => new Date(ms).toLocaleDateString('fr-FR', { timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit', year: 'numeric' }); /** * @param {Array<{domaine, expireMs, noms, erreur}>} lectures * @returns {{ alertes, certificats, texte, prochaine }} * statut : expire | bientot | mauvais_nom | illisible | ok */ export function certificatsAAlerter(lectures, maintenantMs, preavisJ = PREAVIS_J) { const certificats = lectures.map((l) => { if (l.erreur || !Number.isFinite(l.expireMs)) { return { domaine: l.domaine, statut: 'illisible', expire_le: null, jours_restants: null, texte: `${l.domaine} : certificat illisible (${l.erreur || 'date absente'})` }; } const j = Math.floor((l.expireMs - maintenantMs) / 86400000); const base = { domaine: l.domaine, expire_le: new Date(l.expireMs).toISOString(), jours_restants: j }; if (!couvre(l.noms || [], l.domaine)) { return { ...base, statut: 'mauvais_nom', texte: `${l.domaine} : le proxy sert un certificat d'un autre nom (${(l.noms || []).join(', ') || 'inconnu'}) — certificat importe mais pas associe ?` }; } if (l.expireMs <= maintenantMs) { return { ...base, statut: 'expire', texte: `${l.domaine} : certificat EXPIRE depuis le ${jour(l.expireMs)}` }; } if (j < preavisJ) { return { ...base, statut: 'bientot', texte: `${l.domaine} : certificat expire le ${jour(l.expireMs)} (dans ${j} j)` }; } return { ...base, statut: 'ok', texte: `${l.domaine} : valable jusqu'au ${jour(l.expireMs)}` }; }); const ordre = { expire: 0, mauvais_nom: 1, illisible: 2, bientot: 3, ok: 4 }; certificats.sort((a, b) => ordre[a.statut] - ordre[b.statut] || (a.jours_restants ?? 0) - (b.jours_restants ?? 0)); const alertes = certificats.filter((c) => c.statut !== 'ok'); const valides = certificats.filter((c) => Number.isFinite(c.jours_restants) && c.statut !== 'mauvais_nom' && c.statut !== 'expire'); const prochaine = valides.length ? valides.reduce((a, b) => (a.jours_restants <= b.jours_restants ? a : b)) : null; const geste = 'Renouveler sur le .17 (Let\'s Encrypt), exporter, importer sur le .50 et associer au reverse proxy.'; return { alertes, certificats, prochaine: prochaine ? { domaine: prochaine.domaine, expire_le: prochaine.expire_le, jours_restants: prochaine.jours_restants } : null, texte: alertes.length ? `${alertes.map((a) => `- ${a.texte}`).join('\n')}\n${geste}` : `Certificats du reverse proxy valables (prochaine echeance : ${prochaine ? `${prochaine.domaine} dans ${prochaine.jours_restants} j` : 'inconnue'}).`, }; }