[grade=B] DC-107: Disaster recovery — one-click full backup + restore
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:
@@ -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;
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user