const fsp = require('fs').promises; const fs = require('fs'); const path = require('path'); const { CADDY } = require('../../constants'); const { exists } = require('../../fs-helpers'); const { ValidationError, AuthenticationError } = require('../../errors'); module.exports = function({ configStateManager, servicesStateManager, asyncHandler, log }) { const express = require('express'); /** * Config backup routes factory * @param {Object} deps - Explicit dependencies * @param {Object} deps.configStateManager - Config state manager * @param {Object} deps.servicesStateManager - Services state manager * @param {Function} deps.asyncHandler - Async route handler wrapper * @param {Object} deps.log - Logger instance * @returns {express.Router} */ const router = express.Router(); const THEMES_DIR = process.env.THEMES_DIR || path.join(path.dirname(ctx.SERVICES_FILE), 'themes'); function readAllThemes() { const themes = {}; try { if (!fs.existsSync(THEMES_DIR)) return themes; const files = fs.readdirSync(THEMES_DIR).filter(f => f.endsWith('.json')); for (const file of files) { const slug = path.basename(file, '.json'); themes[slug] = JSON.parse(fs.readFileSync(path.join(THEMES_DIR, file), 'utf8')); } } catch (e) { /* ignore */ } return themes; } // ===== BACKUP/RESTORE ENDPOINTS ===== // Unified v2.0 backup — server config + encryption key + themes (browser state added client-side) // Export all configuration as a downloadable JSON bundle router.get('/backup/export', asyncHandler(async (req, res) => { const backup = { version: '2.0', exportedAt: new Date().toISOString(), dashcaddyVersion: '1.0.0', files: {}, themes: {}, assets: {} }; // Collect all configuration files (encryption key now included for self-contained restore) const ENCRYPTION_KEY_FILE = process.env.ENCRYPTION_KEY_FILE || path.join(path.dirname(ctx.SERVICES_FILE), '.encryption-key'); const filesToBackup = [ { key: 'services', path: ctx.SERVICES_FILE, required: true }, { key: 'caddyfile', path: ctx.caddy.filePath, required: true }, { key: 'config', path: ctx.CONFIG_FILE, required: false }, { key: 'dnsCredentials', path: ctx.dns.credentialsFile, required: false }, { key: 'credentials', path: process.env.CREDENTIALS_FILE || path.join(__dirname, '..', '..', 'credentials.json'), required: false }, { key: 'encryptionKey', path: ENCRYPTION_KEY_FILE, required: false }, { key: 'totpConfig', path: ctx.TOTP_CONFIG_FILE, required: false }, { key: 'tailscaleConfig', path: ctx.TAILSCALE_CONFIG_FILE, required: false }, { key: 'notifications', path: ctx.NOTIFICATIONS_FILE, required: false } ]; for (const file of filesToBackup) { try { if (await exists(file.path)) { const content = await fsp.readFile(file.path, 'utf8'); // Try to parse as JSON, otherwise store as raw string try { backup.files[file.key] = { type: 'json', data: JSON.parse(content) }; } catch { backup.files[file.key] = { type: 'text', data: content }; } } else if (file.required) { backup.files[file.key] = { type: 'missing', data: null }; } } catch (e) { log.warn('backup', `Could not backup ${file.key}`, { error: e.message }); } } // Include TOTP QR code for authenticator app recovery if (ctx.totpConfig.isSetUp) { try { const secret = await ctx.credentialManager.retrieve('totp.secret'); if (secret) { const { authenticator } = require('otplib'); const QRCode = require('qrcode'); const otpauth = authenticator.keyuri('user', 'DashCaddy', secret); const qrDataUrl = await QRCode.toDataURL(otpauth, { width: 256, margin: 2, color: { dark: '#000000', light: '#ffffff' } }); backup.totp = { qrCode: qrDataUrl, issuer: 'DashCaddy' }; } } catch (e) { log.warn('backup', 'Could not include TOTP QR in backup', { error: e.message }); } } // Include custom assets (logo, favicon) as base64 try { const assetsDir = process.env.ASSETS_DIR || '/app/assets'; const configData = backup.files.config?.data || {}; const assetFiles = [configData.customLogo, configData.customFavicon] .filter(Boolean) .map(p => p.replace(/^\/assets\//, '')); for (const assetName of assetFiles) { const assetPath = path.join(assetsDir, assetName); if (await exists(assetPath)) { const data = await fsp.readFile(assetPath); backup.assets[assetName] = data.toString('base64'); } } } catch (e) { log.warn('backup', 'Could not include assets in backup', { error: e.message }); } // Include user-created themes try { backup.themes = readAllThemes(); } catch (e) { log.warn('backup', 'Could not include themes in backup', { error: e.message }); } // Set headers for file download const backupFilename = `dashcaddy-backup-${new Date().toISOString().split('T')[0]}.json`; res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Disposition', `attachment; filename="${backupFilename}"`); res.json(backup); log.info('backup', 'Backup exported successfully'); }, 'backup-export')); // Preview what will be restored (without making changes) router.post('/backup/preview', asyncHandler(async (req, res) => { const backup = req.body; if (!backup || !backup.version || !backup.files) { throw new ValidationError('Invalid backup file format'); } const preview = { valid: true, version: backup.version, exportedAt: backup.exportedAt, files: {} }; // Check each file in the backup const ENCRYPTION_KEY_FILE = process.env.ENCRYPTION_KEY_FILE || path.join(path.dirname(ctx.SERVICES_FILE), '.encryption-key'); const fileMapping = { services: { path: ctx.SERVICES_FILE, description: 'Services list' }, caddyfile: { path: ctx.caddy.filePath, description: 'Caddy configuration' }, config: { path: ctx.CONFIG_FILE, description: 'DashCaddy settings' }, dnsCredentials: { path: ctx.dns.credentialsFile, description: 'DNS credentials (legacy)' }, credentials: { path: process.env.CREDENTIALS_FILE || path.join(__dirname, '..', '..', 'credentials.json'), description: 'Encrypted credentials' }, encryptionKey: { path: ENCRYPTION_KEY_FILE, description: 'Encryption key (for credentials)' }, totpConfig: { path: ctx.TOTP_CONFIG_FILE, description: 'TOTP authentication config' }, tailscaleConfig: { path: ctx.TAILSCALE_CONFIG_FILE, description: 'Tailscale config' }, notifications: { path: ctx.NOTIFICATIONS_FILE, description: 'Notification settings' } }; for (const [key, value] of Object.entries(backup.files)) { if (value && value.type !== 'missing') { const mapping = fileMapping[key]; const currentExists = mapping ? await exists(mapping.path) : false; preview.files[key] = { description: mapping?.description || key, inBackup: true, currentExists, action: currentExists ? 'overwrite' : 'create', type: value.type }; } } // Count services if present if (backup.files.services?.data) { const services = Array.isArray(backup.files.services.data) ? backup.files.services.data : backup.files.services.data.services || []; preview.serviceCount = services.length; } // Count themes if present if (backup.themes && typeof backup.themes === 'object') { preview.themeCount = Object.keys(backup.themes).length; } // Count browser state items if present if (backup.browserState && typeof backup.browserState === 'object') { preview.browserStateCount = Object.keys(backup.browserState).length; } res.json({ success: true, preview }); }, 'backup-preview')); // Restore configuration from backup router.post('/backup/restore', asyncHandler(async (req, res) => { const { backup, options = {}, totpCode } = req.body; if (!backup || !backup.version || !backup.files) { throw new ValidationError('Invalid backup file format'); } // Require TOTP verification for restores that include security-sensitive files const sensitiveKeys = ['credentials', 'totpConfig', 'encryptionKey']; const restoresSensitive = sensitiveKeys.some(key => backup.files[key] && backup.files[key].type !== 'missing' && !(options.skip || []).includes(key) ); if (restoresSensitive && ctx.totpConfig.enabled && ctx.totpConfig.isSetUp) { if (!totpCode || !/^\d{6}$/.test(totpCode)) { throw new ValidationError('TOTP code required for restoring security-sensitive files'); } const { authenticator } = require('otplib'); const secret = await ctx.credentialManager.retrieve('totp.secret'); if (secret) { authenticator.options = { window: 1 }; if (!authenticator.verify({ token: totpCode, secret })) { throw new AuthenticationError('[DC-111] Invalid TOTP code'); } } } const results = { restored: [], skipped: [], errors: [] }; const ENCRYPTION_KEY_FILE = process.env.ENCRYPTION_KEY_FILE || path.join(path.dirname(ctx.SERVICES_FILE), '.encryption-key'); const fileMapping = { services: ctx.SERVICES_FILE, caddyfile: ctx.caddy.filePath, config: ctx.CONFIG_FILE, dnsCredentials: ctx.dns.credentialsFile, credentials: process.env.CREDENTIALS_FILE || path.join(__dirname, '..', '..', 'credentials.json'), encryptionKey: ENCRYPTION_KEY_FILE, totpConfig: ctx.TOTP_CONFIG_FILE, tailscaleConfig: ctx.TAILSCALE_CONFIG_FILE, notifications: ctx.NOTIFICATIONS_FILE }; // Restore each file for (const [key, value] of Object.entries(backup.files)) { if (!value || value.type === 'missing') { continue; } // Skip if user chose to skip certain files if (options.skip && options.skip.includes(key)) { results.skipped.push(key); continue; } const filePath = fileMapping[key]; if (!filePath) { results.errors.push({ file: key, error: 'Unknown file type' }); continue; } try { let content; if (value.type === 'json') { content = JSON.stringify(value.data, null, 2); } else { content = value.data; } // Create backup of existing file before overwriting if (await exists(filePath) && options.createBackup !== false) { const backupPath = `${filePath}.bak`; await fsp.copyFile(filePath, backupPath); } await fsp.writeFile(filePath, content, 'utf8'); results.restored.push(key); log.info('backup', `Restored: ${key}`, { path: filePath }); } catch (e) { results.errors.push({ file: key, error: e.message }); } } // Reload Caddy if Caddyfile was restored if (results.restored.includes('caddyfile') && options.reloadCaddy !== false) { try { const caddyContent = await ctx.caddy.read(); const loadResponse = await ctx.fetchT(`${ctx.caddy.adminUrl}/load`, { method: 'POST', headers: { 'Content-Type': CADDY.CONTENT_TYPE }, body: caddyContent }); if (loadResponse.ok) { results.caddyReloaded = true; } else { results.caddyReloadError = await loadResponse.text(); } } catch (e) { results.caddyReloadError = e.message; } } // Reload DNS credentials if restored if (results.restored.includes('dnsCredentials')) { try { ctx.loadDnsCredentials(); results.dnsReloaded = true; } catch (e) { results.dnsReloadError = e.message; } } // Reload notification config if restored if (results.restored.includes('notifications')) { try { await ctx.loadNotificationConfig(); results.notificationsReloaded = true; } catch (e) { results.notificationsReloadError = e.message; } } // Reload site config if restored if (results.restored.includes('config')) { ctx.loadSiteConfig(); results.configReloaded = true; } // Restore custom assets from base64 if (backup.assets && typeof backup.assets === 'object') { const assetsDir = process.env.ASSETS_DIR || '/app/assets'; for (const [name, b64] of Object.entries(backup.assets)) { try { const safeName = path.basename(name); // prevent path traversal await fsp.writeFile(path.join(assetsDir, safeName), Buffer.from(b64, 'base64')); results.restored.push(`asset:${safeName}`); } catch (e) { results.errors.push({ file: `asset:${name}`, error: e.message }); } } } // Restore themes if (backup.themes && typeof backup.themes === 'object' && Object.keys(backup.themes).length) { try { if (!fs.existsSync(THEMES_DIR)) fs.mkdirSync(THEMES_DIR, { recursive: true }); for (const [slug, data] of Object.entries(backup.themes)) { if (/^[a-z0-9-]+$/.test(slug)) { fs.writeFileSync(path.join(THEMES_DIR, slug + '.json'), JSON.stringify(data, null, 2), 'utf8'); } } results.restored.push(`themes:${Object.keys(backup.themes).length}`); log.info('backup', `Restored ${Object.keys(backup.themes).length} themes`); } catch (e) { results.errors.push({ file: 'themes', error: e.message }); } } // Reload encryption key in memory if it was restored if (results.restored.includes('encryptionKey')) { try { // Clear the cached key so crypto-utils reloads from the new file on next use const cryptoUtils = require('../../crypto-utils'); if (typeof cryptoUtils.clearCachedKey === 'function') { cryptoUtils.clearCachedKey(); } results.encryptionKeyReloaded = true; } catch (e) { log.warn('backup', 'Could not reload encryption key', { error: e.message }); } } const success = results.restored.length > 0 && results.errors.length === 0; res.json({ success, message: success ? `Restored ${results.restored.length} file(s) successfully` : `Restore completed with ${results.errors.length} error(s)`, results }); log.info('backup', 'Backup restore completed', { restored: results.restored.length, errors: results.errors.length }); }, 'backup-restore')); return router; };