Files
EVCS/tools/certificats.mjs

85 lines
5.0 KiB
JavaScript

// =====================================================================
// 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'}).`,
};
}