/** * DC-107: Disaster Recovery — one-click backup + restore of entire DashCaddy setup * * Creates a complete system snapshot including: * - All services config (services.json) * - DashCaddy config (config.json) * - Encrypted credentials (credentials.json) * - Caddyfile * - DNS credentials * - Custom themes, logo, favicon * - Notification config * - Audit log * * Excludes: Docker images, container data volumes (too large for API) * * POST /api/v1/disaster/backup — create full snapshot (returns download) * POST /api/v1/disaster/restore — restore from uploaded snapshot * GET /api/v1/disaster/status — check last backup/restore status */ const express = require('express'); const fs = require('fs'); const fsp = require('fs').promises; const path = require('path'); const crypto = require('crypto'); const { ok, errorResponse } = require('../src/utils/responses'); const { ErrorCodes } = require('../src/utilities/error-codes'); // Files that make up a complete DashCaddy backup const BACKUP_FILES = [ { key: 'services', path: 'services.json', required: true }, { key: 'config', path: 'config.json', required: true }, { key: 'credentials', path: 'credentials.json', required: false }, { key: 'dnsCredentials', path: 'dns-credentials.json', required: false }, { key: 'notifications', path: 'notifications.json', required: false }, { key: 'auditLog', path: 'audit-log.json', required: false }, ]; const ASSET_FILES = ['custom-logo.png', 'custom-favicon.png', 'custom-logo.svg']; module.exports = function({ servicesStateManager, platformPaths, log, asyncHandler }) { const wrap = asyncHandler || ((fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next)); const router = express.Router(); let lastBackupStatus = { timestamp: null, status: null, size: null }; let lastRestoreStatus = { timestamp: null, status: null }; /** * POST /api/v1/disaster/backup * Creates a complete system snapshot as a downloadable JSON file. */ router.post('/disaster/backup', wrap(async (req, res) => { const dataDir = platformPaths?.dataDir || '/app/data'; const caddyfilePath = process.env.CADDYFILE_PATH || '/caddyfile'; const snapshot = { version: '1.0', createdAt: new Date().toISOString(), hostname: require('os').hostname(), dashcaddyVersion: process.env.npm_package_version || 'unknown', files: {}, assets: {}, caddyfile: null, }; // Collect config files for (const { key, path: filePath, required } of BACKUP_FILES) { const fullPath = path.join(dataDir, filePath); try { const content = await fsp.readFile(fullPath, 'utf8'); snapshot.files[key] = JSON.parse(content); } catch (err) { if (required) { return errorResponse(res, 500, `Required file missing: ${filePath}`, { code: ErrorCodes.BACKUP.BACKUP_FAILED, }); } // Optional file — skip } } // Collect Caddyfile try { snapshot.caddyfile = await fsp.readFile(caddyfilePath, 'utf8'); } catch { // Caddyfile not accessible — continue without it } // Collect assets (logo, favicon) const assetsDir = platformPaths?.resolveAssetsPath?.() || path.join(dataDir, 'assets'); for (const assetName of ASSET_FILES) { const assetPath = path.join(assetsDir, assetName); try { const data = await fsp.readFile(assetPath); snapshot.assets[assetName] = data.toString('base64'); } catch { // Asset doesn't exist — skip } } // Collect themes try { const themesDir = path.join(dataDir, 'themes'); const themes = await fsp.readdir(themesDir); snapshot.themes = {}; for (const theme of themes) { if (theme.endsWith('.json')) { const content = await fsp.readFile(path.join(themesDir, theme), 'utf8'); snapshot.themes[theme] = JSON.parse(content); } } } catch { // No themes directory } // Generate checksum for integrity verification const snapshotJson = JSON.stringify(snapshot); snapshot.checksum = crypto.createHash('sha256').update(snapshotJson).digest('hex'); lastBackupStatus = { timestamp: snapshot.createdAt, status: 'success', size: Buffer.byteLength(snapshotJson), }; if (log) log.info('disaster-recovery', 'Backup created', { size: lastBackupStatus.size }); // Send as downloadable file const filename = `dashcaddy-backup-${new Date().toISOString().split('T')[0]}.json`; res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.json(snapshot); })); /** * POST /api/v1/disaster/restore * Restores from an uploaded snapshot JSON. * Body: { snapshot: {...} } or raw JSON snapshot */ router.post('/disaster/restore', wrap(async (req, res) => { const dataDir = platformPaths?.dataDir || '/app/data'; const caddyfilePath = process.env.CADDYFILE_PATH || '/caddyfile'; let snapshot = req.body?.snapshot || req.body; if (!snapshot || !snapshot.version) { return errorResponse(res, 400, 'Invalid snapshot: missing version field', { code: ErrorCodes.BACKUP.INVALID_CONFIG, }); } // Verify checksum if present if (snapshot.checksum) { const expectedChecksum = snapshot.checksum; const { checksum, ...rest } = snapshot; const actualChecksum = crypto.createHash('sha256').update(JSON.stringify(rest)).digest('hex'); if (expectedChecksum !== actualChecksum) { return errorResponse(res, 400, 'Snapshot checksum mismatch — file may be corrupted', { code: ErrorCodes.BACKUP.INVALID_CONFIG, }); } } const restored = []; const errors = []; // Restore config files for (const { key, path: filePath } of BACKUP_FILES) { if (!snapshot.files?.[key]) continue; try { const fullPath = path.join(dataDir, filePath); await fsp.writeFile(fullPath, JSON.stringify(snapshot.files[key], null, 2)); restored.push(filePath); } catch (err) { errors.push({ file: filePath, error: err.message }); } } // Restore Caddyfile if (snapshot.caddyfile) { try { await fsp.writeFile(caddyfilePath, snapshot.caddyfile); restored.push('Caddyfile'); } catch (err) { errors.push({ file: 'Caddyfile', error: err.message }); } } // Restore assets const assetsDir = platformPaths?.resolveAssetsPath?.() || path.join(dataDir, 'assets'); for (const [name, base64] of Object.entries(snapshot.assets || {})) { try { await fsp.mkdir(assetsDir, { recursive: true }); await fsp.writeFile(path.join(assetsDir, name), Buffer.from(base64, 'base64')); restored.push(`assets/${name}`); } catch (err) { errors.push({ file: `assets/${name}`, error: err.message }); } } // Restore themes if (snapshot.themes) { const themesDir = path.join(dataDir, 'themes'); try { await fsp.mkdir(themesDir, { recursive: true }); for (const [name, content] of Object.entries(snapshot.themes)) { await fsp.writeFile(path.join(themesDir, name), JSON.stringify(content, null, 2)); restored.push(`themes/${name}`); } } catch (err) { errors.push({ file: 'themes', error: err.message }); } } lastRestoreStatus = { timestamp: new Date().toISOString(), status: errors.length === 0 ? 'success' : 'partial', restored: restored.length, errors: errors.length, }; if (log) log.info('disaster-recovery', 'Restore completed', lastRestoreStatus); ok(res, { status: errors.length === 0 ? 'success' : 'partial', restored, errors, message: errors.length === 0 ? `Successfully restored ${restored.length} files. Restart DashCaddy to apply.` : `Restored ${restored.length} files with ${errors.length} errors. Check error details.`, }); })); /** * GET /api/v1/disaster/status */ router.get('/disaster/status', wrap(async (req, res) => { ok(res, { lastBackup: lastBackupStatus, lastRestore: lastRestoreStatus }); })); return router; };