[grade=B] DC-107: Disaster recovery — one-click full backup + restore
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s

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.
This commit is contained in:
Hermes
2026-08-12 12:52:16 -07:00
parent 0cda298651
commit 184ec2e49f
2 changed files with 250 additions and 0 deletions
+241
View File
@@ -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;
};
+9
View File
@@ -65,6 +65,7 @@ const discoverRoutes = require('../routes/discover');
const discoverAdoptRoutes = require('../routes/discover-adopt'); const discoverAdoptRoutes = require('../routes/discover-adopt');
const catalogRoutes = require('../routes/catalog'); const catalogRoutes = require('../routes/catalog');
const wizardRoutes = require('../routes/wizard'); const wizardRoutes = require('../routes/wizard');
const disasterRoutes = require('../routes/disaster-recovery');
const configRoutes = require('../routes/config'); const configRoutes = require('../routes/config');
const dnsRoutes = require('../routes/dns'); const dnsRoutes = require('../routes/dns');
const notificationRoutes = require('../routes/notifications'); const notificationRoutes = require('../routes/notifications');
@@ -632,6 +633,14 @@ async function createApp() {
APP_TEMPLATES: require('./docker/app-templates'), APP_TEMPLATES: require('./docker/app-templates'),
asyncHandler: ctx.asyncHandler, 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({ apiRouter.use(updatesRoutes({
updateManager: ctx.updateManager, updateManager: ctx.updateManager,
selfUpdater: ctx.selfUpdater, selfUpdater: ctx.selfUpdater,