#!/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);