Alerte d'expiration des certificats HTTPS du reverse proxy (sensor.evcs_certificats)

This commit is contained in:
Tallagrand Thierry
2026-10-13 05:25:55 +02:00
parent 6a42cce5e5
commit 4e144739ee
4 changed files with 152 additions and 0 deletions

84
tools/certificats.mjs Normal file
View File

@@ -0,0 +1,84 @@
// =====================================================================
// 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'}).`,
};
}

View File

@@ -34,6 +34,7 @@ import { texteRapport, envoyer, donneesNuit, texteDepuis } from './rapport-nuit.
import { donneesRevue, constatsRevue } from './revue-jour.mjs';
import { detecterSituation, lireChoix, DUREE_MIN as ARBITRAGE_DUREE_MIN } from './arbitrage.mjs';
import { pilesAAlerter } from './piles.mjs';
import { DOMAINES, lireCertificat, certificatsAAlerter } from './certificats.mjs';
import { diagnosticPv, etatCellules, productionJour, chargeSolaire, bilanNuit, etatReseau, importJour, minuitLocal, COMPTEURS_IMPORT, COMPTEUR_EDF, consoJour, COMPTEURS_CONSO, projectionSoir, previsionNuit, vrmVersCourbes, nuitMaisonPrevue } from './diagnostics-agent.mjs';
import { transitionsDansSerie } from './allers-retours.mjs';
import { energieSur24h, energieDepuis, COMPTEUR_CHAUFFE_EAU } from './chauffe-eau.mjs';
@@ -2133,6 +2134,26 @@ async function appliquerOrdreAirtria(sql, client, st) {
// WhatsApp. input_text.evcs_piles_ignorees (liste separee par des virgules)
// pour faire taire un appareil.
let pilesPubliees = false;
// ---------------------------------------------------------------------
// CERTIFICATS HTTPS DU REVERSE PROXY .50 (13/10/2026, tools/certificats.mjs)
// sensor.evcs_certificats = nombre d'alertes (expire, < 14 j, mauvais nom,
// illisible) ; attributs alertes, certificats, prochaine, texte. Lu au
// demarrage puis toutes les heures (minute 8). SPARK previent par WhatsApp.
// ---------------------------------------------------------------------
let certificatsPublies = false;
async function publierCertificats() {
const lectures = [];
for (const d of DOMAINES) lectures.push(await lireCertificat(d));
const c = certificatsAAlerter(lectures, Date.now());
await publierCapteur('sensor.evcs_certificats', c.alertes.length, {
friendly_name: 'Certificats HTTPS du reverse proxy', icon: 'mdi:certificate-outline',
alertes: c.alertes, certificats: c.certificats, prochaine: c.prochaine, texte: c.texte,
lu_a: new Date().toISOString(),
});
certificatsPublies = true;
return 1;
}
async function publierPiles() {
const r = await fetch(`${HA_URL}/api/states`, { headers: { Authorization: `Bearer ${HA_TOKEN}` }, signal: AbortSignal.timeout(15000) });
if (!r.ok) throw new Error(`HA ${r.status}`);
@@ -2727,6 +2748,9 @@ async function cycle(client) {
piles: (DRY || (pilesPubliees && new Date().getMinutes() % 15 !== 6)) ? 0 : await publierPiles().catch((e) => {
console.error('piles :', e.message); return 0;
}),
certificats: (DRY || (certificatsPublies && new Date().getMinutes() !== 8)) ? 0 : await publierCertificats().catch((e) => {
console.error('certificats :', e.message); return 0;
}),
arbitrage: DRY ? 0 : await arbitrer(sql, client, st).catch((e) => {
console.error('arbitrage :', e.message); return 0;
}),