Full codebase including API server (32 modules + routes), dashboard frontend, DashCA certificate distribution, installer script, and deployment skills.
305 lines
11 KiB
JavaScript
305 lines
11 KiB
JavaScript
const fsp = require('fs').promises;
|
|
const path = require('path');
|
|
const { CADDY } = require('../../constants');
|
|
const { exists } = require('../../fs-helpers');
|
|
|
|
module.exports = function(ctx) {
|
|
const express = require('express');
|
|
const router = express.Router();
|
|
|
|
// ===== BACKUP/RESTORE ENDPOINTS =====
|
|
// Export and import DashCaddy configuration
|
|
|
|
// Export all configuration as a downloadable JSON bundle
|
|
router.get('/backup/export', ctx.asyncHandler(async (req, res) => {
|
|
const backup = {
|
|
version: '1.1',
|
|
exportedAt: new Date().toISOString(),
|
|
dashcaddyVersion: '1.0.0',
|
|
files: {},
|
|
assets: {}
|
|
};
|
|
|
|
// Collect all configuration files
|
|
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) {
|
|
ctx.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, manualKey: secret, issuer: 'DashCaddy' };
|
|
}
|
|
} catch (e) {
|
|
ctx.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) {
|
|
ctx.log.warn('backup', 'Could not include assets 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);
|
|
ctx.log.info('backup', 'Backup exported successfully');
|
|
}, 'backup-export'));
|
|
|
|
// Preview what will be restored (without making changes)
|
|
router.post('/backup/preview', ctx.asyncHandler(async (req, res) => {
|
|
const backup = req.body;
|
|
|
|
if (!backup || !backup.version || !backup.files) {
|
|
return ctx.errorResponse(res, 400, '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;
|
|
}
|
|
|
|
res.json({ success: true, preview });
|
|
}, 'backup-preview'));
|
|
|
|
// Restore configuration from backup
|
|
router.post('/backup/restore', ctx.asyncHandler(async (req, res) => {
|
|
const { backup, options = {} } = req.body;
|
|
|
|
if (!backup || !backup.version || !backup.files) {
|
|
return ctx.errorResponse(res, 400, 'Invalid backup file format');
|
|
}
|
|
|
|
const results = {
|
|
restored: [],
|
|
skipped: [],
|
|
errors: []
|
|
};
|
|
|
|
// File mapping
|
|
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);
|
|
ctx.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 });
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
});
|
|
|
|
ctx.log.info('backup', 'Backup restore completed', { restored: results.restored.length, errors: results.errors.length });
|
|
}, 'backup-restore'));
|
|
|
|
return router;
|
|
};
|