From 184ec2e49f084206c41f79ce22cb58513816f9aa Mon Sep 17 00:00:00 2001 From: Hermes Date: Wed, 12 Aug 2026 12:52:16 -0700 Subject: [PATCH] =?UTF-8?q?[grade=3DB]=20DC-107:=20Disaster=20recovery=20?= =?UTF-8?q?=E2=80=94=20one-click=20full=20backup=20+=20restore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3 endpoints: - POST /api/v1/disaster/backup — complete snapshot (services, config, credentials, Caddyfile, DNS creds, themes, logo, favicon) as downloadable JSON with SHA-256 checksum - POST /api/v1/disaster/restore — restore from uploaded snapshot with checksum verification - GET /api/v1/disaster/status — last backup/restore status Checksum verification prevents restoring corrupted snapshots. Partial restore mode continues on per-file errors. 1633 tests pass. --- dashcaddy-api/routes/disaster-recovery.js | 241 ++++++++++++++++++++++ dashcaddy-api/src/app.js | 9 + 2 files changed, 250 insertions(+) create mode 100644 dashcaddy-api/routes/disaster-recovery.js diff --git a/dashcaddy-api/routes/disaster-recovery.js b/dashcaddy-api/routes/disaster-recovery.js new file mode 100644 index 0000000..f6f40c2 --- /dev/null +++ b/dashcaddy-api/routes/disaster-recovery.js @@ -0,0 +1,241 @@ +/** + * 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; +}; diff --git a/dashcaddy-api/src/app.js b/dashcaddy-api/src/app.js index 096cb92..27e421b 100644 --- a/dashcaddy-api/src/app.js +++ b/dashcaddy-api/src/app.js @@ -65,6 +65,7 @@ const discoverRoutes = require('../routes/discover'); const discoverAdoptRoutes = require('../routes/discover-adopt'); const catalogRoutes = require('../routes/catalog'); const wizardRoutes = require('../routes/wizard'); +const disasterRoutes = require('../routes/disaster-recovery'); const configRoutes = require('../routes/config'); const dnsRoutes = require('../routes/dns'); const notificationRoutes = require('../routes/notifications'); @@ -632,6 +633,14 @@ async function createApp() { APP_TEMPLATES: require('./docker/app-templates'), asyncHandler: ctx.asyncHandler, })); + + // DC-107: Disaster recovery — full backup + restore + apiRouter.use(disasterRoutes({ + servicesStateManager: ctx.servicesStateManager, + platformPaths: require('../platform-paths'), + log: ctx.log, + asyncHandler: ctx.asyncHandler, + })); apiRouter.use(updatesRoutes({ updateManager: ctx.updateManager, selfUpdater: ctx.selfUpdater,