const express = require('express'); const path = require('path'); const fs = require('fs'); const { DOCKER } = require('../../src/utilities/constants'); const { ok, validationError, notFound, errorResponse } = require('../../src/utils/responses'); const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, '..', 'backups'); /** * Apps restore routes factory * @param {Object} deps - Explicit dependencies * @param {Object} deps.docker - Docker client wrapper * @param {Object} deps.caddy - Caddy client * @param {Object} deps.servicesStateManager - Services state manager * @param {Function} deps.asyncHandler - Async route handler wrapper * @param {Function} deps.errorResponse - Error response helper * @param {Object} deps.log - Logger instance * @param {Object} deps.helpers - Apps helpers module * @param {Object} deps.APP_TEMPLATES - App templates registry * @param {Object} deps.dns - DNS client * @param {Function} deps.buildServiceUrl - Service URL builder * @returns {express.Router} */ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, errorResponse, log, helpers, APP_TEMPLATES, dns, buildServiceUrl }) { const router = express.Router(); const ctx = { APP_TEMPLATES, dns, buildServiceUrl }; /** * Restore a single service from its deployment manifest. * Pulls image, creates container, starts it, recreates Caddy config. * Skips if container is already running. */ router.post('/:appId/restore', asyncHandler(async (req, res) => { const { appId } = req.params; const services = await servicesStateManager.read(); const service = services.find(s => s.id === appId); if (!service) { return errorResponse(res, 404, `Service "${appId}" not found in services.json`); } if (!service.deploymentManifest) { return errorResponse(res, 400, `Service "${appId}" has no deployment manifest — it was deployed before the manifest feature was added. Redeploy it manually to create a manifest.`); } const result = await restoreService(service); ok(res, { result }); }, 'apps-restore')); /** * Restore all services that have deployment manifests. * Returns per-service results. */ router.post('/restore-all', asyncHandler(async (req, res) => { const services = await servicesStateManager.read(); const restoreable = services.filter(s => s.deploymentManifest); if (restoreable.length === 0) { return ok(res, { message: 'No services have deployment manifests to restore', results: [] }); } const results = []; for (const service of restoreable) { try { const result = await restoreService(service); results.push(result); } catch (error) { results.push({ id: service.id, name: service.name, status: 'failed', error: error.message }); } } const succeeded = results.filter(r => r.status === 'restored').length; const skipped = results.filter(r => r.status === 'skipped').length; const failed = results.filter(r => r.status === 'failed').length; ok(res, { message: `Restore complete: ${succeeded} restored, ${skipped} skipped, ${failed} failed`, results }); }, 'apps-restore-all')); /** * List all services and their restore status. */ router.get('/restore-status', asyncHandler(async (req, res) => { const services = await servicesStateManager.read(); const status = []; for (const service of services) { const entry = { id: service.id, name: service.name, hasManifest: !!service.deploymentManifest, templateId: service.deploymentManifest?.templateId || service.appTemplate || null, deployedAt: service.deployedAt || null, containerRunning: false }; // Check if container is currently running if (service.containerId) { try { const container = docker.client.getContainer(service.containerId); const info = await container.inspect(); entry.containerRunning = info.State.Running; } catch (e) { entry.containerRunning = false; } } status.push(entry); } ok(res, { services: status }); }, 'apps-restore-status')); // ==================== POINT-IN-TIME RESTORE (BACKUP FILE BASED) ==================== // Get available backup files for a specific app router.get('/:appId/backup-points', asyncHandler(async (req, res) => { const { appId } = req.params; const backupDir = DEFAULT_BACKUP_DIR; const files = []; try { if (fs.existsSync(backupDir)) { const entries = fs.readdirSync(backupDir, { withFileTypes: true }); for (const entry of entries) { if (entry.isFile() && entry.name.endsWith('.backup')) { try { const nameWithoutExt = entry.name.replace('.backup', ''); const parts = nameWithoutExt.split('-'); const fileAppId = parts[0]; // Only include files for the requested app if (fileAppId !== appId) continue; const filepath = path.join(backupDir, entry.name); const stats = fs.statSync(filepath); const timestamp = parts.length > 1 ? parseInt(parts[parts.length - 1]) : stats.mtimeMs; files.push({ name: entry.name, appId: fileAppId, size: stats.size, sizeFormatted: formatBytes(stats.size), timestamp: new Date(timestamp).toISOString(), modified: stats.mtime.toISOString(), path: filepath }); } catch (err) { // Skip malformed filenames } } } } } catch (err) { // Directory might not exist yet } // Sort by timestamp descending (newest first) files.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); ok(res, { appId, isBackupFile: true, files, total: files.length }); }, 'apps-backup-points')); // Revert a specific app to a backup file (point-in-time restore) router.post('/:appId/revert/:filename', asyncHandler(async (req, res) => { const { appId, filename } = req.params; const { encryptionKey, restartContainers } = req.body || {}; // Security: prevent path traversal if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) { return validationError(res, 'Invalid filename'); } const filepath = path.join(DEFAULT_BACKUP_DIR, filename); if (!fs.existsSync(filepath)) { return notFound(res, `Backup file not found: ${filename}`); } try { // Read the backup file let fileData = fs.readFileSync(filepath); // Decrypt if needed if (encryptionKey) { try { fileData = await backupManager.decryptBackup(fileData, encryptionKey); } catch (err) { return validationError(res, 'Failed to decrypt backup: ' + err.message); } } // Decompress const backupData = await backupManager.decompressBackup(fileData); // Extract to temp directory const os = require('os'); const crypto = require('crypto'); const tempDir = path.join(os.tmpdir(), `dashcaddy-revert-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`); fs.mkdirSync(tempDir, { recursive: true }); try { const tarPath = path.join(tempDir, 'backup.tar.gz'); fs.writeFileSync(tarPath, backupData); const { execSync } = require('child_process'); try { execSync(`cd "${tempDir}" && tar -xzf backup.tar.gz`, { stdio: 'pipe' }); } catch (tarErr) { throw new Error('Failed to extract backup archive: ' + tarErr.message); } // Read manifest if present let manifest = null; const manifestPath = path.join(tempDir, 'manifest.json'); if (fs.existsSync(manifestPath)) { try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } catch (_) {} } // Read app-specific data const appServicesPath = path.join(tempDir, 'services.json'); const appConfigPath = path.join(tempDir, 'config.json'); const appCredsPath = path.join(tempDir, 'credentials.json'); let restoreData = { services: null, config: null, credentials: null }; if (fs.existsSync(appServicesPath)) { try { restoreData.services = JSON.parse(fs.readFileSync(appServicesPath, 'utf8')); } catch (_) {} } if (fs.existsSync(appConfigPath)) { try { restoreData.config = JSON.parse(fs.readFileSync(appConfigPath, 'utf8')); } catch (_) {} } if (fs.existsSync(appCredsPath)) { try { restoreData.credentials = JSON.parse(fs.readFileSync(appCredsPath, 'utf8')); } catch (_) {} } // If restartContainers is true, actually perform the restore if (restartContainers) { if (restoreData.services) backupManager.restoreServices(restoreData.services); if (restoreData.config) backupManager.restoreConfig(restoreData.config); if (restoreData.credentials) backupManager.restoreCredentials(restoreData.credentials); // Cleanup temp dir fs.rmSync(tempDir, { recursive: true, force: true }); ok(res, { isBackupFile: true, restored: { services: !!restoreData.services, config: !!restoreData.config, credentials: !!restoreData.credentials }, message: `${appId} reverted to backup successfully` }); } else { // Preview mode fs.rmSync(tempDir, { recursive: true, force: true }); ok(res, { isBackupFile: true, preview: true, filename, appId, manifest, restoreData: { hasServices: !!restoreData.services, hasConfig: !!restoreData.config, hasCredentials: !!restoreData.credentials } }); } } catch (err) { try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch (_) {} throw err; } } catch (err) { errorResponse(res, 500, err.message); } }, 'apps-revert')); /** * Core restore logic for a single service. */ async function restoreService(service) { const manifest = service.deploymentManifest; const template = ctx.APP_TEMPLATES[manifest.templateId]; log.info('restore', `Restoring service: ${service.name}`, { id: service.id, templateId: manifest.templateId }); // Static sites: just recreate Caddy config if (template?.isStaticSite) { log.info('restore', `Restoring static site Caddy config: ${service.name}`); // Static site Caddy config would need to be regenerated // For now, just confirm the service entry exists return { id: service.id, name: service.name, status: 'restored', type: 'static', message: `Static site "${service.name}" config preserved` }; } // Docker container: check if already running if (service.containerId) { try { const existing = docker.client.getContainer(service.containerId); const info = await existing.inspect(); if (info.State.Running) { log.info('restore', `Container already running, skipping: ${service.name}`); return { id: service.id, name: service.name, status: 'skipped', message: 'Container already running' }; } } catch (e) { // Container doesn't exist — proceed with restore } } // Also check by name (container ID may have changed) const containerName = `${DOCKER.CONTAINER_PREFIX}${manifest.config.subdomain}`; try { const byName = docker.client.getContainer(containerName); const info = await byName.inspect(); if (info.State.Running) { // Update the service entry with the current container ID await servicesStateManager.update(services => { const svc = services.find(s => s.id === service.id); if (svc) svc.containerId = info.Id; return services; }); return { id: service.id, name: service.name, status: 'skipped', message: 'Container already running (found by name)' }; } // Exists but not running — remove stale container await byName.remove({ force: true }); } catch (e) { // Container doesn't exist — proceed } if (!manifest.container) { return { id: service.id, name: service.name, status: 'failed', error: 'No container configuration in manifest' }; } // Pull image log.info('restore', `Pulling image: ${manifest.container.image}`); try { await docker.pull(manifest.container.image); } catch (e) { // Check if image exists locally const images = await docker.client.listImages({ filters: { reference: [manifest.container.image] } }); if (images.length === 0) { throw new Error(`Failed to pull image ${manifest.container.image}: ${e.message}`); } log.warn('restore', `Pull failed, using local image: ${manifest.container.image}`); } // Build container config from manifest const containerConfig = { Image: manifest.container.image, name: containerName, ExposedPorts: {}, HostConfig: { PortBindings: {}, Binds: manifest.container.volumes || [], RestartPolicy: { Name: 'unless-stopped' }, LogConfig: DOCKER.LOG_CONFIG }, Env: Object.entries(manifest.container.environment || {}).map(([k, v]) => `${k}=${v}`), Labels: { 'sami.managed': 'true', 'sami.app': manifest.templateId, 'sami.subdomain': manifest.config.subdomain, 'sami.deployed': new Date().toISOString(), 'sami.restored': 'true' } }; // Set up port bindings (manifest.container.ports || []).forEach(portMapping => { const [hostPort, containerPort, protocol = 'tcp'] = portMapping.split(/[:/]/); const containerPortKey = `${containerPort}/${protocol}`; containerConfig.ExposedPorts[containerPortKey] = {}; containerConfig.HostConfig.PortBindings[containerPortKey] = [{ HostPort: hostPort }]; }); if (manifest.container.capabilities) { containerConfig.HostConfig.CapAdd = manifest.container.capabilities; } // Create and start container log.info('restore', `Creating container: ${containerName}`); const container = await docker.client.createContainer(containerConfig); await container.start(); log.info('restore', `Container started: ${containerName}`); // Recreate Caddy config const port = manifest.config.port; const caddyOptions = { tailscaleOnly: manifest.caddy.tailscaleOnly, allowedIPs: manifest.caddy.allowedIPs, subpathSupport: manifest.caddy.subpathSupport, }; if (manifest.caddy.routingMode === 'subdirectory') { const caddyConfig = caddy.generateConfig(manifest.config.subdomain, manifest.config.ip, port, caddyOptions); try { await helpers.ensureMainDomainBlock(); await helpers.addSubpathConfig(manifest.config.subdomain, caddyConfig); } catch (e) { log.warn('restore', `Caddy config may already exist: ${e.message}`); } } else { const caddyConfig = caddy.generateConfig(manifest.config.subdomain, manifest.config.ip, port, caddyOptions); try { await helpers.addCaddyConfig(manifest.config.subdomain, caddyConfig); } catch (e) { log.warn('restore', `Caddy config may already exist: ${e.message}`); } } // DNS record if (manifest.config.createDns && manifest.caddy.routingMode !== 'subdirectory') { try { await ctx.dns.universalCreateRecord(manifest.config.subdomain, manifest.config.ip); log.info('restore', 'DNS record recreated', { subdomain: manifest.config.subdomain }); } catch (e) { log.warn('restore', `DNS recreation failed: ${e.message}`); } } // Update the service entry with the new container ID await servicesStateManager.update(services => { const svc = services.find(s => s.id === service.id); if (svc) { svc.containerId = container.id; svc.url = buildServiceUrl(manifest.config.subdomain); } return services; }); return { id: service.id, name: service.name, status: 'restored', type: 'container', containerId: container.id, message: `${service.name} restored successfully` }; } return router; }; // Helper: format bytes to human readable function formatBytes(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }