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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
This commit is contained in:
Tallagrand Thierry
2026-09-01 03:13:55 +02:00
parent ccd764beb9
commit 57c696928d
3 changed files with 244 additions and 0 deletions

134
tools/ha-backup-config.mjs Normal file
View File

@@ -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);

48
tools/ha-backup-list.mjs Normal file
View File

@@ -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);