From 57c696928d29b07fc2951c971833c369f84dec35 Mon Sep 17 00:00:00 2001 From: Tallagrand Thierry Date: Tue, 1 Sep 2026 03:13:55 +0200 Subject: [PATCH] Sauvegarde Home Assistant : configuree, poussee sur deux autres machines, verifiee Aucune sauvegarde HA n avait jamais ete faite, et git ne la couvrait pas -- un seul fichier HA y etait versionne. HA declarait pourtant trois destinations Synology, dont 10.10.10.22 et une troisieme machine en 10.10.10.50 absente de toute la documentation du projet. Quotidienne 04:45, sept copies, base d historique exclue (20 Mo au lieu de 4,4 Go), poussee sur les trois machines. La configuration passe par le websocket car l API REST ne l expose pas. Verifiee archive en main : .storage, packages, dashboards, secrets et custom_components presents, base d historique absente comme voulu. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ --- HANDOFF.md | 62 +++++++++++++++++ tools/ha-backup-config.mjs | 134 +++++++++++++++++++++++++++++++++++++ tools/ha-backup-list.mjs | 48 +++++++++++++ 3 files changed, 244 insertions(+) create mode 100644 tools/ha-backup-config.mjs create mode 100644 tools/ha-backup-list.mjs diff --git a/HANDOFF.md b/HANDOFF.md index d3149b7..2ce5cba 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -2259,3 +2259,65 @@ Le contacteur EDF reste incommandable si internet tombe (Tuya cloud). Un contacteur local — ou un `localtuya` — est le prochain vrai gain de résilience. La sauvegarde (6 Mo d'identité vers Gitea, 6,1 Go d'historique en hebdomadaire avec `sqlite3 .backup`) et le secours froid n8n restent à faire. + +## 34. Session du 01/09/2026 (nuit) — Sauvegarde Home Assistant : elle n'existait pas, elle existe + +### Le point de départ +Aucune sauvegarde HA n'avait jamais été faite (§32). Et **non, git ne la +couvrait pas** : le dépôt contient le code du contrôleur, pas Home Assistant. +Un seul fichier HA y était versionné (`dashboards/bilan_energetique.yaml`). +Les helpers, les entités, les paquets, les cartes, les intégrations n'avaient +aucune copie nulle part. + +### La trouvaille qui a simplifié le problème +`backup/agents/info` déclare **quatre destinations**, et le croisement avec les +entrées `synology_dsm` de `.storage/core.config_entries` donne leur hôte réel — +le nom seul ne dit pas où chacune écrit : + +| agent | nom | hôte | +|---|---|---| +| `backup.local` | local | 10.10.10.17 | +| `synology_dsm.1660NZN396007` | ARCHIVE2 | **10.10.10.22** | +| `synology_dsm.14B0MPN914514` | ARCHIVE | **10.10.10.50** | +| `synology_dsm.20A0SBRVM9PF2` | ARCHIVE3 | 10.10.10.17 ⚠️ à ne PAS utiliser | + +Home Assistant sait donc **pousser lui-même ses sauvegardes vers deux autres +machines**. Aucun script de recopie, aucune tâche planifiée, aucun montage NFS +supplémentaire : il n'y avait qu'à le configurer. (Troisième machine 10.10.10.50 +découverte au passage — elle n'apparaissait dans aucun document du projet.) + +### Configuration appliquée (`tools/ha-backup-config.mjs`, rejouable) +La configuration de sauvegarde **n'est pas exposée par l'API REST** : elle vit +dans `.storage/backup` et ne se règle que par l'interface ou le **websocket**. +Le script passe par le websocket, ce qui la rend reproductible et relisible. + +- **Destinations** : local + 10.10.10.22 + 10.10.10.50. ARCHIVE3 (10.10.10.17) + volontairement exclu : une sauvegarde qui ne survit pas à la panne qu'elle + couvre ne protège rien. +- **Base de données EXCLUE.** Ce qu'il faut restaurer vite, c'est l'identité du + système — 20 Mo compressés. L'historique pèse 4,4 Go, change en permanence et + n'a aucune valeur d'urgence : il se sauvegarde à part, plus rarement, et avec + les précautions SQLite (mode WAL, jamais un `cp` à chaud). +- **Quotidienne à 04:45**, rétention **7 copies**. +- **Sans mot de passe**, choix assumé : ces archives restent sur le réseau local + du propriétaire, et une sauvegarde restaurable vaut mieux qu'une sauvegarde + chiffrée dont la clé est perdue le jour où elle sert. + +### Vérifiée, pas seulement déclenchée +Première archive : `Automatic backup 2026.8.1` (a9d35ac9), 01/09 03:11, +**20,3 Mo, présente sur les trois machines**. Contenu contrôlé archive en main : +`.storage` 39 fichiers · `packages` 18 · `dashboards` 30 · +`custom_components` 2 283 · `configuration.yaml`, `secrets.yaml`, +`automations.yaml`, `scripts.yaml` · et **zéro occurrence** de +`home-assistant_v2.db`, comme voulu. +`tools/ha-backup-list.mjs` affiche pour chaque sauvegarde les hôtes où elle est +réellement présente — c'est cette colonne-là qui compte. + +### Ce que cette sauvegarde ne couvre PAS +1. **n8n.** Ni ses workflows (déjà en git, donc couverts autrement), ni sa base + `database.sqlite` (1,69 Go), ni surtout **la clé de chiffrement de 56 octets** + (§32) sans laquelle aucun credential n'est récupérable. C'est le prochain + trou à boucher, et le plus petit. +2. **L'historique HA** (4,4 Go) : perdu en cas de restauration. Conséquence + réelle pour le chantier du bilan énergétique, qui s'appuie sur les + statistiques long terme. À traiter séparément, en hebdomadaire. diff --git a/tools/ha-backup-config.mjs b/tools/ha-backup-config.mjs new file mode 100644 index 0000000..23a7dc9 --- /dev/null +++ b/tools/ha-backup-config.mjs @@ -0,0 +1,134 @@ +#!/usr/bin/env node +/** + * Configure la sauvegarde automatique de Home Assistant (installation Core / + * Container, HA 2026.8.1 — pas de Supervisor ici). + * + * POURQUOI CE SCRIPT PLUTÔT QUE L'INTERFACE + * La configuration de sauvegarde n'est PAS exposée par l'API REST : elle vit + * dans `.storage/backup` et ne se règle que par l'interface ou par le + * websocket. Passer par le websocket la rend reproductible, versionnée, et + * relisible dans six mois — ce qu'un clic dans l'interface n'est pas. + * + * CE QUI EST RÉGLÉ, ET POURQUOI + * - Destinations : les agents Synology **ARCHIVE2 (10.10.10.22)** et + * **ARCHIVE (10.10.10.50)**, plus une copie locale. Le NAS principal + * (ARCHIVE3, 10.10.10.17) est délibérément EXCLU comme destination : une + * sauvegarde qui ne survit pas à la panne qu'elle couvre ne sert à rien. + * - Base de données EXCLUE. Ce qu'il faut pouvoir restaurer vite, c'est + * l'identité du système (entités, helpers, cartes, paquets, intégrations) : + * environ 60 Mo. L'historique, lui, pèse 4,4 Go, change en permanence, et + * n'a aucune valeur d'urgence — il se sauvegarde séparément, plus rarement, + * et avec les précautions SQLite (mode WAL : jamais un `cp` à chaud). + * - Rétention 7 copies : une semaine pour s'apercevoir d'une bêtise. + * - Pas de mot de passe. Choix assumé : ces archives restent sur le réseau + * local du propriétaire, et une sauvegarde restaurable vaut mieux qu'une + * sauvegarde chiffrée dont la clé est perdue le jour où elle sert. + * + * Usage : + * node tools/ha-backup-config.mjs --dry-run (affiche avant/après, n'écrit rien) + * node tools/ha-backup-config.mjs (applique) + * node tools/ha-backup-config.mjs --run-now (applique puis déclenche une sauvegarde) + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const HA_URL = process.env.HA_URL || 'http://10.10.10.17:8123'; +const ARGS = new Set(process.argv.slice(2)); +const DRY = ARGS.has('--dry-run'); +const RUN_NOW = ARGS.has('--run-now'); + +function loadToken() { + if (process.env.HA_TOKEN) return process.env.HA_TOKEN; + const m = fs.readFileSync(path.join(ROOT, '.env'), 'utf8').match(/^\s*HA_TOKEN\s*=\s*(.+?)\s*$/m); + if (!m) { console.error('HA_TOKEN absent de .env'); process.exit(1); } + return m[1].replace(/^["']|["']$/g, ''); +} + +// Agents de destination. Identifiants relevés via `backup/agents/info` et +// croisés avec les entrées `synology_dsm` de `.storage/core.config_entries` +// pour connaître l'hôte réel de chacun — le nom seul ne dit pas où il écrit. +const AGENTS = { + local: 'backup.local', // 10.10.10.17, restauration rapide + archive2: 'synology_dsm.1660NZN396007', // 10.10.10.22 — second Synology + archive: 'synology_dsm.14B0MPN914514', // 10.10.10.50 — troisième machine + // 'synology_dsm.20A0SBRVM9PF2' = ARCHIVE3 = 10.10.10.17 : NE PAS UTILISER + // comme destination, c'est la machine dont on se protège. +}; + +const DESIRED = { + create_backup: { + agent_ids: [AGENTS.local, AGENTS.archive2, AGENTS.archive], + include_database: false, + include_folders: null, + include_all_addons: false, + include_addons: null, + name: null, + password: null, + }, + retention: { copies: 7, days: null }, + schedule: { recurrence: 'daily', time: '04:45:00', days: [] }, +}; + +const token = loadToken(); +const ws = new WebSocket(HA_URL.replace(/^http/, 'ws') + '/api/websocket'); +let seq = 1; +const pending = new Map(); + +function call(msg) { + return new Promise((resolve, reject) => { + const id = seq++; + pending.set(id, { resolve, reject }); + ws.send(JSON.stringify({ id, ...msg })); + }); +} + +ws.onmessage = async (ev) => { + const m = JSON.parse(ev.data); + if (m.type === 'auth_required') return ws.send(JSON.stringify({ type: 'auth', access_token: token })); + if (m.type === 'auth_invalid') { console.error('jeton HA refusé'); process.exit(1); } + if (m.type === 'auth_ok') return main().catch(e => { console.error(e.message); process.exit(1); }); + if (m.type === 'result') { + const p = pending.get(m.id); pending.delete(m.id); + if (!p) return; + m.success ? p.resolve(m.result) : p.reject(new Error(JSON.stringify(m.error))); + } +}; +ws.onerror = () => { console.error(`websocket injoignable sur ${HA_URL}`); process.exit(1); }; + +async function main() { + const before = (await call({ type: 'backup/config/info' })).config; + console.log('=== AVANT ==='); + console.log(` configurée : ${before.automatic_backups_configured}`); + console.log(` récurrence : ${before.schedule.recurrence} à ${before.schedule.time ?? '—'}`); + console.log(` destinations : ${before.create_backup.agent_ids.join(', ') || '— aucune —'}`); + console.log(` base incluse : ${before.create_backup.include_database}`); + console.log(` rétention : ${before.retention.copies ?? '—'} copies`); + + console.log('\n=== APRÈS (souhaité) ==='); + console.log(` récurrence : ${DESIRED.schedule.recurrence} à ${DESIRED.schedule.time}`); + console.log(' destinations :'); + console.log(` ${AGENTS.local} → 10.10.10.17 (copie locale, restauration rapide)`); + console.log(` ${AGENTS.archive2} → 10.10.10.22 (second Synology)`); + console.log(` ${AGENTS.archive} → 10.10.10.50 (troisième machine)`); + console.log(` base incluse : ${DESIRED.create_backup.include_database} (les 4,4 Go d'historique se sauvegardent à part)`); + console.log(` rétention : ${DESIRED.retention.copies} copies`); + + if (DRY) { console.log('\n[DRY-RUN] aucune écriture.'); ws.close(); process.exit(0); } + + await call({ type: 'backup/config/update', ...DESIRED }); + const after = (await call({ type: 'backup/config/info' })).config; + console.log(`\n✅ appliqué — configurée=${after.automatic_backups_configured}, ` + + `prochaine=${after.next_automatic_backup ?? '?'}`); + + if (RUN_NOW) { + console.log('\nDéclenchement d\'une sauvegarde de vérification…'); + await call({ type: 'backup/generate_with_automatic_settings' }); + console.log(' demandée. Suivre avec: node tools/ha-backup-list.mjs'); + } + ws.close(); process.exit(0); +} + +setTimeout(() => { console.error('délai dépassé'); process.exit(1); }, 60000); diff --git a/tools/ha-backup-list.mjs b/tools/ha-backup-list.mjs new file mode 100644 index 0000000..9a97354 --- /dev/null +++ b/tools/ha-backup-list.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +// Liste les sauvegardes Home Assistant et, pour chacune, les destinations où +// elle est RÉELLEMENT présente. C'est cette dernière colonne qui compte : une +// sauvegarde qui n'existe que sur la machine dont on se protège ne protège rien. +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const HA_URL = process.env.HA_URL || 'http://10.10.10.17:8123'; +const token = process.env.HA_TOKEN || + fs.readFileSync(path.join(ROOT, '.env'), 'utf8').match(/^\s*HA_TOKEN\s*=\s*(.+?)\s*$/m)[1].replace(/^["']|["']$/g, ''); + +const HOST = { + 'backup.local': '10.10.10.17 (local)', + 'synology_dsm.1660NZN396007': '10.10.10.22', + 'synology_dsm.14B0MPN914514': '10.10.10.50', + 'synology_dsm.20A0SBRVM9PF2': '10.10.10.17', +}; + +const ws = new WebSocket(HA_URL.replace(/^http/, 'ws') + '/api/websocket'); +ws.onmessage = (ev) => { + const m = JSON.parse(ev.data); + if (m.type === 'auth_required') return ws.send(JSON.stringify({ type: 'auth', access_token: token })); + if (m.type === 'auth_ok') return ws.send(JSON.stringify({ id: 1, type: 'backup/info' })); + if (m.type === 'result') { + const r = m.result || {}; + if (r.backups?.length) { + for (const b of r.backups) { + const mo = (b.agents ? Object.entries(b.agents) : []); + console.log(`\n${b.name} (${b.backup_id})`); + console.log(` créée : ${new Date(b.date).toLocaleString('fr-FR', { timeZone: 'Europe/Paris' })}`); + console.log(` base incluse : ${b.database_included} automatique : ${b.with_automatic_settings}`); + for (const [agent, info] of mo) { + const mb = info.size ? (info.size / 1048576).toFixed(1) + ' Mo' : '?'; + console.log(` → ${(HOST[agent] || agent).padEnd(22)} ${mb.padStart(9)}`); + } + } + } else { + console.log('Aucune sauvegarde.'); + } + if (r.next_automatic_backup) { + console.log(`\nProchaine automatique : ${new Date(r.next_automatic_backup).toLocaleString('fr-FR', { timeZone: 'Europe/Paris' })}`); + } + ws.close(); process.exit(0); + } +}; +setTimeout(() => { console.error('délai dépassé'); process.exit(1); }, 30000);