From 4e144739ee37634748ce45f9845203eee7daad92 Mon Sep 17 00:00:00 2001 From: Tallagrand Thierry Date: Tue, 13 Oct 2026 05:25:55 +0200 Subject: [PATCH] Alerte d'expiration des certificats HTTPS du reverse proxy (sensor.evcs_certificats) --- HANDOFF.md | 5 +++ tests/certificats/run.mjs | 39 ++++++++++++++++++ tools/certificats.mjs | 84 +++++++++++++++++++++++++++++++++++++++ tools/historise-evcs.mjs | 24 +++++++++++ 4 files changed, 152 insertions(+) create mode 100644 tests/certificats/run.mjs create mode 100644 tools/certificats.mjs diff --git a/HANDOFF.md b/HANDOFF.md index ad4d905..693d012 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -2398,5 +2398,10 @@ batterie a 32 A et JEEP (branchee 13:01, 52 %) qui ne demarre pas. SOC 47 % a 12 - ⚠️ **Certificat HTTPS de n8n.airtria.pro expire le 12/10 a 15:52 UTC** (Let's Encrypt, non renouvele) : MCP n8n et `n8n-push` par defaut en echec. Deploiement fait par l'acces interne : `N8N_URL=http://10.10.10.17:5678` + `N8N_API_KEY` (lu dans opencode.json). Le n8n vivant est sur le .17 (`n8n_ha_proxy-n8n-1`) ; celui du .22 (`n8n_ha_a_revoir`) refuse la cle. Renouvellement a faire par le proprietaire (reverse proxy non documente ici). - Kilometrage : niveaux deduits de l'autonomie marques `photo_autonomie:/` (SPARK) ; `capacite_estimee.niveau_estime` ; VW 12/10 14 -> 55 % (estime), 3,60 kWh, **8,78 kWh**. +### 13/10 — Alerte d'expiration des certificats HTTPS (reverse proxy .50) +- Reverse proxy = Synology **10.10.10.50** (DSM sur le port 5355). Certificats expires : n8n, gitlab, open-webui (12/10), openclaw (13/10 20:29 UTC). Le bouton « Renouveler » du .50 ne fait qu'une CSR (certificats importes) et sa demande Let's Encrypt echoue (« echec de la connexion ») : le proprietaire obtient le certificat sur le **.17** (port 80 de la box vers le .17 pendant la validation), l'exporte et l'importe sur le .50, puis l'associe au reverse proxy. Ma cle SSH n'est pas autorisee sur le .50. +- `tools/certificats.mjs` : lit le certificat REELLEMENT servi par le .50 pour chaque nom (poignee de main TLS, SNI, aucune requete HTTP) ; `sensor.evcs_certificats` = nombre d'alertes (expire / < 14 j / mauvais nom = importe mais pas associe / illisible), attributs alertes, certificats, prochaine, texte. Au demarrage puis toutes les heures (minute 8). Tests `node tests/certificats/run.mjs` 10/10, deux mutations qui mordent. Premier releve reel : 4 alertes. SPARK relaie par WhatsApp. +- airtria.fr / airtria.com ne sont pas servis par le .50 (le proxy y renvoie son certificat par defaut) : hors liste (`DOMAINES`). + --- *Document mis a jour le : 10/10/2026 (~16:00) — conseiller relecteur de la journee (revue calculee + pistes) ; nuit : voiture qui profite d'EDF ferme pour la batterie, nuit qui prepare le soir les jours annonces faibles ; 08/10/2026 (~00:15) — jour faible : charge maximale en HC de midi (bilan 100 %, ACM plafond + solaire) ; 07/10/2026 (~22:45) — reprise de 16 h verifiee, EDF en HP a 18:03, bilan : reprise du soir et prevision recalee sur la matinee ; bilan HC de midi, reprise du soir apres coupure en pleine chauffe, mesure ECS sur 10 min ; recharge reseau HC : courant calcule (12 kVA) et consigne non tenue ; HC de midi : AUTO ne chauffe pas (BOOST), alerte v2, Jeep (contact, relecture, zone), recharge batterie bridee a 30 A par l'ACM ; jour faible = EDF les deux fenetres HC pour le chauffe-eau, alerte BOOST, Victron Mobile refait ; 1re decision jour faible en reel, mesure ECS reparee, douches 06:00/20:00, Grafana chauffe-eau, tableaux ECS ; chauffe-eau en HC de nuit les jours faibles (decision du bilan, workflow ECS sous controle du depot, VRM, V2.3 desactive) ; garde batterie reseau : voiture bridee a 6 A au lieu d'un arret ; garde-fou concierge 2 h ; allers-retours brefs de la borne retrouves dans l'historique HA (echange VW -> JEEP de 20 s), production 14,5 kWh contre 22 prevus, defaut garde batterie en mode reseau a trancher ; voiture a midi : la batterie n'est plus comptee comme du solaire (EDF automatique les jours de depart si le surplus ne couvre pas) ; nuit 05->06 : JEEP chargee sur EDF en complete (6,32 kWh), budget 0 du a la prevision, Windy/ECMWF contre VRM, a comparer ce soir ; concierge aligne sur la preuve fin de trajet ; a verifier : nuit 05->06 et mardi faible, comparaison VRM/Forecast.Solar ; JEEP identifiee aussi par sa fin de trajet, prevision de la nuit 05->06 ; identification JEEP reussie en reel, correction humaine qui prime (refus supprime), session du 04/10 coupee ; EVCS Master en quatre onglets ; Jeep lue par Uconnect et identifiee seule, capacite JEEP 10 kWh, concierge variante (a) ; releve par photo (SPARK), hass-uconnect en attente ; ecart semaine/week-end mesure (pas de profil week-end la nuit) ; nuit du budget : budget borne au plancher prudent, sans EDF = voiture bloquee meme EDF ferme (batterie seule) ; Energie : sauts corriges ; tableau Energie complete (Sud/Ouest, batterie par integration, borne ; aucun redemarrage HA) ; kilometrage des vehicules (km/kWh, capacite, releves de reference, agent cable 15/15) ; a verifier le 04/10 au matin : nuit du budget JEEP ; concierge : vehicule non confirme demande, heure de saisie ; Grafana vehicules en tableau ; Grafana vehicules : ZERO et Invite ajoutes, session 16:46 reetiquetee ZERO ; prevision SOC 10 h ~35 % ; concierge fiable : niveau note par le script, une ligne, dedoublonnage, simulation par drapeau ; agent : message incompris = question, bourse sur demande explicite (banc final 6/6) ; a verifier : nuit du samedi 03 au dimanche 04/10 (budget < besoin JEEP), cron orange-cloture lundi 05/10 apres 17:40, comparaison VRM / Forecast.Solar lundi 05/10* diff --git a/tests/certificats/run.mjs b/tests/certificats/run.mjs new file mode 100644 index 0000000..64bec63 --- /dev/null +++ b/tests/certificats/run.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node +// Tests de l'alerte d'expiration des certificats (tools/certificats.mjs, 13/10/2026). +// Releve reel du 13/10 ~05:15 : n8n expire le 12/10 15:52 UTC, openclaw le 13/10 20:29. +import { certificatsAAlerter, PREAVIS_J } from '../../tools/certificats.mjs'; + +let echecs = 0; +const ok = (id, cond, info = '') => { if (cond) console.log(`✅ ${id}`); else { echecs++; console.log(`❌ ${id} ${info}`); } }; +const now = Date.parse('2026-10-13T03:15:00Z'); +const L = (domaine, iso, noms = [domaine], erreur = null) => ({ domaine, expireMs: iso ? Date.parse(iso) : null, noms, erreur }); + +{ const r = certificatsAAlerter([L('n8n.airtria.pro', '2026-10-12T15:52:00Z'), L('openclaw.airtria.pro', '2026-10-13T20:29:07Z')], now); + const n = r.alertes.find((a) => a.domaine === 'n8n.airtria.pro'); + const o = r.alertes.find((a) => a.domaine === 'openclaw.airtria.pro'); + ok('releve-1310-n8n-expire', n && n.statut === 'expire' && /EXPIRE depuis le 12\/10\/2026/.test(n.texte), JSON.stringify(n)); + ok('releve-1310-openclaw-bientot', o && o.statut === 'bientot' && o.jours_restants === 0, JSON.stringify(o)); + ok('expire-avant-bientot', r.alertes[0].domaine === 'n8n.airtria.pro'); + ok('prochaine-echeance-a-venir', r.prochaine?.domaine === 'openclaw.airtria.pro', JSON.stringify(r.prochaine)); + ok('geste-dans-le-texte', /\.17/.test(r.texte) && /\.50/.test(r.texte), r.texte); } + +{ const r = certificatsAAlerter([L('n8n.airtria.pro', '2027-01-10T00:00:00Z')], now); + ok('valable-pas-d-alerte', r.alertes.length === 0 && r.prochaine.jours_restants > 80 && /valables/.test(r.texte), r.texte); } + +{ // juste au preavis : 14 j pile -> pas d'alerte ; 13 j -> alerte + const a = certificatsAAlerter([L('x.airtria.pro', new Date(now + PREAVIS_J * 86400000 + 3600000).toISOString())], now); + const b = certificatsAAlerter([L('x.airtria.pro', new Date(now + (PREAVIS_J - 1) * 86400000 + 3600000).toISOString())], now); + ok('preavis-borne', a.alertes.length === 0 && b.alertes.length === 1 && b.alertes[0].statut === 'bientot', JSON.stringify([a.alertes, b.alertes])); } + +{ // import fait mais pas associe : le proxy sert son certificat par defaut (constate 13/10 pour airtria.fr) + const r = certificatsAAlerter([L('n8n.airtria.pro', '2034-09-11T22:43:20Z', ['synology.com', 'email:product@synology.com'])], now); + ok('mauvais-nom', r.alertes[0]?.statut === 'mauvais_nom' && /synology\.com/.test(r.alertes[0].texte), JSON.stringify(r.alertes)); } + +{ const r = certificatsAAlerter([L('a.airtria.pro', '2027-01-10T00:00:00Z', ['*.airtria.pro'])], now); + ok('joker-couvre', r.alertes.length === 0, JSON.stringify(r.alertes)); } + +{ const r = certificatsAAlerter([L('n8n.airtria.pro', null, [], 'ECONNREFUSED')], now); + ok('illisible', r.alertes[0]?.statut === 'illisible' && /ECONNREFUSED/.test(r.alertes[0].texte)); } + +console.log(echecs ? `\n${echecs} ECHEC(S)` : '\nCERTIFICATS OK'); +process.exit(echecs ? 1 : 0); diff --git a/tools/certificats.mjs b/tools/certificats.mjs new file mode 100644 index 0000000..2bcb84b --- /dev/null +++ b/tools/certificats.mjs @@ -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'}).`, + }; +} diff --git a/tools/historise-evcs.mjs b/tools/historise-evcs.mjs index b4b0b7c..7bcee7e 100644 --- a/tools/historise-evcs.mjs +++ b/tools/historise-evcs.mjs @@ -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; }),