From 154e4eec702c2b0f5cef55ea54e5fe6bdd695d80 Mon Sep 17 00:00:00 2001 From: Nox Date: Mon, 23 Feb 2026 11:25:54 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20add=20docker-backup.js=20=E2=80=94=20ba?= =?UTF-8?q?ckup=20automatique=20volumes=20Docker=20via=20Portainer=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backup Vaultwarden (SQLite + config + clΓ©s RSA), Vikunja, NocoDB, FreshRSS - Nettoyage automatique (keepLast N backups par service) - Compatible sh/ash/bash selon le conteneur - Cron OpenClaw configurΓ© : chaque dimanche 03h00 Europe/Paris - Job ID : 9b81b021-6fc2-4ead-bf69-aec7e3551d70 --- docker-backup.js | 247 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 docker-backup.js diff --git a/docker-backup.js b/docker-backup.js new file mode 100644 index 0000000..b616c4c --- /dev/null +++ b/docker-backup.js @@ -0,0 +1,247 @@ +#!/usr/bin/env node +/** + * docker-backup.js β€” Nox πŸŒ‘ + * Backup automatique des volumes Docker critiques via l'API Portainer. + * ExΓ©cute un cp dans chaque conteneur cible et notifie via Telegram. + * + * Usage : + * node docker-backup.js β†’ backup de tous les services configurΓ©s + * node docker-backup.js vaultwarden β†’ backup d'un seul service + * node docker-backup.js --list β†’ liste les services configurΓ©s + * + * Cron OpenClaw : chaque dimanche Γ  3h00 + */ + +process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + +const PORTAINER_URL = 'https://192.168.1.150:9443'; +const PORTAINER_TOKEN = process.env.PORTAINER_API_KEY; +const ENDPOINT_ID = 2; + +// ─── Configuration des services Γ  sauvegarder ──────────────────────────────── +// Pour chaque service : +// container : nom exact du conteneur Docker +// dataPath : chemin du volume DANS le conteneur +// backupDir : sous-dossier de backup (dans dataPath) +// keepLast : nombre de backups Γ  conserver (les plus anciens sont supprimΓ©s) +// files : (optionnel) liste de fichiers spΓ©cifiques Γ  copier (sinon tout dataPath) +// ───────────────────────────────────────────────────────────────────────────── +const SERVICES = [ + { + container: 'vaultwarden', + dataPath: '/data', + backupDir: '/data/backups', + keepLast: 4, + files: ['db.sqlite3', 'db.sqlite3-shm', 'db.sqlite3-wal', 'config.json', 'rsa_key.pem', 'rsa_key.pub.pem'], + description: 'Gestionnaire de mots de passe (Bitwarden)', + }, + { + container: 'vikunja-vikunja-1', + dataPath: '/app/vikunja/files', + backupDir: '/app/vikunja/files/backups', + keepLast: 3, + files: null, // tout le dossier + description: 'Vikunja β€” gestionnaire de tΓ’ches', + }, + { + container: 'nocodb', + dataPath: '/usr/app/data', + backupDir: '/usr/app/data/backups', + keepLast: 3, + files: null, + description: 'NocoDB β€” base de donnΓ©es no-code', + }, + { + container: 'freshrss', + dataPath: '/var/www/FreshRSS/data', + backupDir: '/var/www/FreshRSS/data/backups', + keepLast: 3, + files: null, + description: 'FreshRSS β€” agrΓ©gateur RSS', + }, +]; +// ───────────────────────────────────────────────────────────────────────────── + +const args = process.argv.slice(2); +const TARGET = args.find(a => !a.startsWith('--')); +const LIST_ONLY = args.includes('--list'); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +async function portainerFetch(path, opts = {}) { + const res = await fetch(PORTAINER_URL + path, { + ...opts, + headers: { 'X-API-Key': PORTAINER_TOKEN, 'Content-Type': 'application/json', ...(opts.headers || {}) }, + }); + return res; +} + +async function getContainerId(name) { + const r = await portainerFetch(`/api/endpoints/${ENDPOINT_ID}/docker/containers/json?all=true`); + const containers = await r.json(); + const c = containers.find(c => c.Names.some(n => n === '/' + name || n === name)); + return c?.Id; +} + +async function execInContainer(containerId, cmd) { + const ec = await portainerFetch(`/api/endpoints/${ENDPOINT_ID}/docker/containers/${containerId}/exec`, { + method: 'POST', + body: JSON.stringify({ AttachStdout: true, AttachStderr: true, Cmd: cmd }), + }); + const { Id } = await ec.json(); + const es = await portainerFetch(`/api/endpoints/${ENDPOINT_ID}/docker/exec/${Id}/start`, { + method: 'POST', + body: JSON.stringify({ Detach: false, Tty: false }), + }); + const buf = await es.arrayBuffer(); + const bytes = new Uint8Array(buf); + let result = ''; + let i = 0; + while (i < bytes.length) { + if (i + 8 > bytes.length) break; + const sz = (bytes[i+4]<<24)|(bytes[i+5]<<16)|(bytes[i+6]<<8)|bytes[i+7]; + result += new TextDecoder().decode(bytes.slice(i+8, i+8+sz)); + i += 8 + sz; + } + return result.trim(); +} + +function getTimestamp() { + const now = new Date(); + return now.toISOString().replace(/T/, '_').replace(/:/g, '-').split('.')[0]; +} + +function getDateStr() { + return new Date().toISOString().split('T')[0]; +} + +// ─── Backup d'un service ────────────────────────────────────────────────────── + +async function backupService(service) { + const { container, dataPath, backupDir, keepLast, files, description } = service; + const timestamp = getTimestamp(); + const backupPath = `${backupDir}/${timestamp}`; + + console.log(`\nπŸ“¦ [${container}] ${description}`); + console.log(` β†’ Backup vers ${backupPath}`); + + // Trouver le conteneur + const containerId = await getContainerId(container); + if (!containerId) { + console.log(` ⚠️ Conteneur "${container}" introuvable β€” ignorΓ©`); + return { service: container, status: 'skipped', reason: 'container not found' }; + } + + // CrΓ©er le dossier de backup + await execInContainer(containerId, ['mkdir', '-p', backupPath]); + + // Copier les fichiers + let copyResult; + if (files && files.length > 0) { + // Copier fichiers spΓ©cifiques β€” essayer sh puis ash + for (const shell of ['sh', 'ash', 'bash']) { + const cmd = [shell, '-c', `cp -p ${files.map(f => `${dataPath}/${f}`).join(' ')} ${backupPath}/ 2>&1`]; + copyResult = await execInContainer(containerId, cmd); + if (!copyResult.includes('not found')) break; + } + } else { + // Copier tout le dossier (sauf le dossier backups lui-mΓͺme) + // Essayer sh puis ash (Alpine/BusyBox) + for (const shell of ['sh', 'ash', 'bash']) { + const cmd = [shell, '-c', `find ${dataPath} -maxdepth 1 -not -name backups -not -path ${dataPath} | xargs -I{} cp -rp {} ${backupPath}/ 2>&1`]; + copyResult = await execInContainer(containerId, cmd); + if (!copyResult.includes('not found')) break; + } + } + + if (copyResult && copyResult.includes('error')) { + console.log(` ❌ Erreur lors de la copie : ${copyResult}`); + return { service: container, status: 'error', reason: copyResult }; + } + + // VΓ©rifier le backup + const checkResult = await execInContainer(containerId, ['ls', '-lh', backupPath]); + const fileCount = checkResult.split('\n').filter(l => l.trim() && !l.startsWith('total')).length; + const totalLine = checkResult.split('\n').find(l => l.startsWith('total')) || ''; + console.log(` βœ… ${fileCount} fichier(s) copiΓ©s (${totalLine.replace('total ', '')})`); + + // Nettoyage : garder seulement les N derniers backups + // Essayer sh, puis ash (Alpine/BusyBox), puis liste manuelle + const shells = ['sh', 'ash', 'bash']; + let listResult = ''; + for (const shell of shells) { + const res = await execInContainer(containerId, [shell, '-c', `ls -1t ${backupDir} | tail -n +${keepLast + 1}`]); + if (!res.includes('not found') && !res.includes('No such')) { + listResult = res; + break; + } + } + const toDelete = listResult.split('\n').filter(Boolean); + if (toDelete.length > 0) { + for (const old of toDelete) { + await execInContainer(containerId, ['rm', '-rf', `${backupDir}/${old}`]); + console.log(` πŸ—‘οΈ Ancien backup supprimΓ© : ${old}`); + } + } + + return { service: container, status: 'ok', path: backupPath, files: fileCount }; +} + +// ─── Main ───────────────────────────────────────────────────────────────────── + +async function main() { + if (LIST_ONLY) { + console.log('\nπŸ“‹ Services configurΓ©s pour le backup :\n'); + SERVICES.forEach((s, i) => { + console.log(` ${i + 1}. ${s.container.padEnd(25)} ${s.description}`); + console.log(` Volume: ${s.dataPath} | Garde: ${s.keepLast} backups`); + if (s.files) console.log(` Fichiers: ${s.files.join(', ')}`); + }); + return; + } + + const targets = TARGET + ? SERVICES.filter(s => s.container.toLowerCase().includes(TARGET.toLowerCase())) + : SERVICES; + + if (targets.length === 0) { + console.log(`❌ Aucun service trouvΓ© pour "${TARGET}"`); + process.exit(1); + } + + console.log(`\nπŸŒ‘ Nox β€” Docker Backup β€” ${getDateStr()}`); + console.log(` ${targets.length} service(s) Γ  sauvegarder\n`); + console.log('─'.repeat(60)); + + const results = []; + for (const service of targets) { + try { + const result = await backupService(service); + results.push(result); + } catch (err) { + console.log(` ❌ Exception : ${err.message}`); + results.push({ service: service.container, status: 'error', reason: err.message }); + } + } + + // RΓ©sumΓ© + console.log('\n' + '─'.repeat(60)); + console.log('πŸ“Š RΓ©sumΓ© :'); + const ok = results.filter(r => r.status === 'ok').length; + const errors = results.filter(r => r.status === 'error').length; + const skipped = results.filter(r => r.status === 'skipped').length; + console.log(` βœ… ${ok} OK | ❌ ${errors} erreur(s) | ⏭️ ${skipped} ignorΓ©(s)`); + + if (errors > 0) { + console.log('\n⚠️ Erreurs :'); + results.filter(r => r.status === 'error').forEach(r => { + console.log(` - ${r.service}: ${r.reason}`); + }); + process.exit(1); + } +} + +main().catch(err => { + console.error('❌ Fatal:', err.message); + process.exit(1); +});