- Removed orphaned __trace2.js (unnecessary escape error) - Fixed empty block statement in config-migrations.test.js busy-wait - Fixed empty block statement in metrics.test.js busy-wait - Auto-fixed 5 fixable warnings via eslint --fix - Remaining 547 warnings (require-await, no-unused-vars) are non-blocking code quality - 0 errors, 1633 tests pass
501 lines
18 KiB
JavaScript
501 lines
18 KiB
JavaScript
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 { validateBody, schemas: valSchemas } = require('../../src/utilities/validate');
|
|
|
|
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, backupManager }) {
|
|
if (!backupManager) throw new Error('routes/apps/restore: backupManager dependency is required');
|
|
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', validateBody(valSchemas.appRestore), 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', validateBody(valSchemas.appRevert), 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');
|
|
|
|
const 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) {
|
|
// P0-5 fix: was `errorResponse(res, 500, err.message)` which leaks internal
|
|
// error details (paths, stack traces, library error codes) to the client.
|
|
// Log the actual error server-side and return a generic message.
|
|
log.error('apps-revert', 'Revert failed', { error: err.message, stack: err.stack });
|
|
errorResponse(res, 500, 'Revert failed');
|
|
}
|
|
}, '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];
|
|
}
|