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