Files
BetterDesk/web-nodejs/services/backupService.js
T
UNITRONIX 684bd06c6e feat(backup): full disaster-recovery backup archive
Redesign the console backup into a complete .tar.gz disaster-recovery bundle that can be restored on a fresh machine to bring the entire server back online. Adds zero-dependency tar.gz writer/reader (backupArchive.js), logical dumpAllTables/importAllTables for SQLite and PostgreSQL, and packs the console database, auth.db, .env, .session_secret, branding uploads, and Go server identity (Ed25519 keys, .api_key, db_v2.sqlite3) plus a recovery README. Restore auto-detects archive vs legacy JSON snapshot, supports per-component selection (database/uploads/secrets/.env/Go DB) with restart-required signaling, and warns that the archive contains secrets. EN/PL/ZH i18n added.

This commit was made possible thanks to Insolve.
2026-06-01 05:21:07 +02:00

684 lines
25 KiB
JavaScript

/**
* BetterDesk Console - Backup & Restore Service
*
* Creates/restores JSON snapshots of console configuration:
* - Console settings (key/value pairs)
* - Branding configuration
* - Users (admin/operator accounts — passwords are hashed)
* - Folders structure
* - User groups, device groups, strategies
* - Address books
* - Go server peers + blocklist + config (fetched via REST when available)
*
* Archive format: single JSON file (*.betterdesk-backup.json)
*/
const db = require('./database');
const brandingService = require('./brandingService');
const serverBackend = require('./serverBackend');
const config = require('../config/config');
const fs = require('fs');
const path = require('path');
const archive = require('./backupArchive');
// Current backup format version — increment on breaking schema changes
const BACKUP_FORMAT_VERSION = 1;
// Full disaster-recovery archive format identifier + version
const FULL_BACKUP_FORMAT = 'betterdesk-full-backup';
const FULL_BACKUP_VERSION = 1;
// ========================== Export ========================================
/**
* Build a full backup payload.
* @returns {Promise<Object>} Serialisable backup object
*/
async function createBackup() {
const timestamp = new Date().toISOString();
// --- Console local data ---
const settings = await db.getAllSettings();
const branding = brandingService.getBranding();
const users = await db.getAllUsersForBackup();
const folders = await db.getAllFolders();
const userGroups = await db.getAllUserGroups();
const deviceGroups = await db.getAllDeviceGroups();
const strategies = await db.getAllStrategies();
// Address books (all users)
const addressBooks = await db.getAllAddressBooks();
// --- Go server data (best-effort) ---
let goServer = null;
if (serverBackend.isBetterDesk()) {
goServer = await fetchGoServerData();
}
return {
_format: 'betterdesk-backup',
_version: BACKUP_FORMAT_VERSION,
_created: timestamp,
_console_version: config.appVersion,
_backend: serverBackend.getActiveBackend(),
console: {
settings,
branding,
users,
folders,
userGroups,
deviceGroups,
strategies,
addressBooks
},
goServer
};
}
/**
* Fetch data from BetterDesk Go server via REST API.
* Non-critical — returns null on failure.
*/
async function fetchGoServerData() {
try {
const betterdeskApi = require('./betterdeskApi');
const [peersRes, blocklistRes, auditRes, healthRes] = await Promise.all([
betterdeskApi.getAllPeers().catch(() => []),
betterdeskApi.getBlocklist().catch(() => []),
betterdeskApi.getAuditEvents(500).catch(() => []),
betterdeskApi.getHealth().catch(() => ({}))
]);
return {
peers: peersRes || [],
blocklist: blocklistRes || [],
auditEvents: auditRes || [],
serverHealth: healthRes || {}
};
} catch {
return null;
}
}
// ========================== Import ========================================
/**
* Validate a backup payload before restoring.
* @param {Object} data - Parsed backup JSON
* @returns {{ valid: boolean, errors: string[] }}
*/
function validateBackup(data) {
const errors = [];
if (!data || typeof data !== 'object') {
errors.push('Invalid backup file: not a JSON object');
return { valid: false, errors };
}
if (data._format !== 'betterdesk-backup') {
errors.push('Invalid backup file: missing or wrong _format field');
}
if (typeof data._version !== 'number' || data._version > BACKUP_FORMAT_VERSION) {
errors.push(`Unsupported backup version: ${data._version} (max supported: ${BACKUP_FORMAT_VERSION})`);
}
if (!data.console || typeof data.console !== 'object') {
errors.push('Invalid backup file: missing console section');
}
return { valid: errors.length === 0, errors };
}
/**
* Restore console data from a backup payload.
* Only restores console-local data. Go server data is informational.
*
* @param {Object} data - Validated backup payload
* @param {Object} options
* @param {boolean} options.restoreSettings - Restore console settings (default true)
* @param {boolean} options.restoreBranding - Restore branding/theme (default true)
* @param {boolean} options.restoreUsers - Restore user accounts (default false — destructive!)
* @param {boolean} options.restoreFolders - Restore folder structure (default true)
* @param {boolean} options.restoreGroups - Restore user/device groups + strategies (default true)
* @param {boolean} options.restoreAddressBooks - Restore address books (default true)
* @returns {{ restored: string[], skipped: string[], warnings: string[] }}
*/
async function restoreBackup(data, options = {}) {
const {
restoreSettings = true,
restoreBranding = true,
restoreUsers = false,
restoreFolders = true,
restoreGroups = true,
restoreAddressBooks = true
} = options;
const result = { restored: [], skipped: [], warnings: [] };
// --- Settings ---
if (restoreSettings && data.console.settings) {
try {
const settings = data.console.settings;
for (const [key, value] of Object.entries(settings)) {
await db.setSetting(key, value);
}
result.restored.push('settings');
} catch (err) {
result.warnings.push(`Settings restore failed: ${err.message}`);
}
} else {
result.skipped.push('settings');
}
// --- Branding ---
if (restoreBranding && data.console.branding) {
try {
await brandingService.saveBranding(data.console.branding);
result.restored.push('branding');
} catch (err) {
result.warnings.push(`Branding restore failed: ${err.message}`);
}
} else {
result.skipped.push('branding');
}
// --- Users (destructive — replaces all users) ---
if (restoreUsers && Array.isArray(data.console.users) && data.console.users.length > 0) {
try {
await db.restoreUsers(data.console.users);
result.restored.push('users');
} catch (err) {
result.warnings.push(`Users restore failed: ${err.message}`);
}
} else {
result.skipped.push('users');
}
// --- Folders ---
if (restoreFolders && Array.isArray(data.console.folders)) {
try {
// Merge: insert folders that don't exist
const existingFolders = await db.getAllFolders();
const existing = new Set(existingFolders.map(f => f.name));
for (const f of data.console.folders) {
if (!existing.has(f.name)) {
await db.createFolder(f.name, f.color || '#6366f1', f.icon || 'folder');
}
}
result.restored.push('folders');
} catch (err) {
result.warnings.push(`Folders restore failed: ${err.message}`);
}
} else {
result.skipped.push('folders');
}
// --- User Groups + Device Groups + Strategies ---
if (restoreGroups) {
try {
await restoreGroupsData(data.console, result);
result.restored.push('groups');
} catch (err) {
result.warnings.push(`Groups restore failed: ${err.message}`);
}
} else {
result.skipped.push('groups');
}
// --- Address Books ---
if (restoreAddressBooks && Array.isArray(data.console.addressBooks)) {
try {
for (const ab of data.console.addressBooks) {
if (ab.user_id && ab.ab_type) {
await db.saveAddressBook(ab.user_id, ab.ab_type, ab.data || '{}');
}
}
result.restored.push('addressBooks');
} catch (err) {
result.warnings.push(`Address books restore failed: ${err.message}`);
}
} else {
result.skipped.push('addressBooks');
}
return result;
}
/**
* Restore user groups, device groups and strategies (merge, don't duplicate).
*/
async function restoreGroupsData(consoleData, result) {
// User groups
if (Array.isArray(consoleData.userGroups)) {
const existing = new Set((await db.getAllUserGroups()).map(g => g.guid));
for (const g of consoleData.userGroups) {
if (g.guid && !existing.has(g.guid)) {
try {
await db.createUserGroup({ guid: g.guid, name: g.name, note: g.note || '' });
} catch { /* duplicate guid — skip */ }
}
}
}
// Device groups
if (Array.isArray(consoleData.deviceGroups)) {
const existing = new Set((await db.getAllDeviceGroups()).map(g => g.guid));
for (const g of consoleData.deviceGroups) {
if (g.guid && !existing.has(g.guid)) {
try {
await db.createDeviceGroup({ guid: g.guid, name: g.name, note: g.note || '' });
} catch { /* duplicate guid — skip */ }
}
}
}
// Strategies
if (Array.isArray(consoleData.strategies)) {
const existing = new Set((await db.getAllStrategies()).map(s => s.guid));
for (const s of consoleData.strategies) {
if (s.guid && !existing.has(s.guid)) {
try {
await db.createStrategy({
guid: s.guid,
name: s.name,
user_group_guid: s.user_group_guid || '',
device_group_guid: s.device_group_guid || '',
enabled: s.enabled !== undefined ? s.enabled : 1,
permissions: typeof s.permissions === 'string' ? s.permissions : JSON.stringify(s.permissions || {})
});
} catch { /* duplicate guid — skip */ }
}
}
}
}
/**
* Get size estimate for a backup (useful for UI info).
* @returns {{ tables: Object<string, number>, totalRows: number }}
*/
async function getBackupStats() {
const stats = await db.getBackupStats();
return {
...stats,
backend: serverBackend.getActiveBackend()
};
}
// ===================== Full Disaster-Recovery Backup ======================
//
// Unlike the JSON snapshot above, the full backup bundles everything required
// to rebuild the server identity and data on a brand-new machine:
// - manifest.json metadata + component inventory
// - console/data.json logical dump of ALL console DB tables
// - console/auth.db raw SQLite copy (sqlite backend only)
// - console/.env environment (SESSION_SECRET, API key, DSN, ports, TLS)
// - console/.session_secret session signing secret
// - console/uploads/* branding image files
// - goserver/data.json Go server peers/blocklist/audit (informational)
// - goserver/id_ed25519(.pub) server identity keypair (client trust + relay)
// - goserver/.api_key console <-> Go server API key
// - goserver/db_v2.sqlite3 raw Go server DB (when readable)
// - README.txt human-readable recovery instructions
//
// SECURITY: the archive contains secrets (keys, .env, password hashes, 2FA
// secrets). It MUST be stored securely and never shared. The UI warns about
// this and most secret components require an explicit opt-in to restore.
/**
* Resolve the absolute paths of every file-based backup component.
*/
function resolveBackupPaths() {
return {
env: path.join(__dirname, '..', '.env'),
sessionSecret: path.join(config.dataDir, '.session_secret'),
uploadsDir: path.join(config.dataDir, 'uploads'),
consoleDb: db.getDatabaseFilePath(),
goPrivKey: path.join(config.keysPath, 'id_ed25519'),
goPubKey: config.pubKeyPath,
goApiKey: config.apiKeyPath,
goDb: config.dbPath
};
}
/**
* Add a file to the archive entry list if it exists and is readable.
*/
function addFileIfExists(entries, archiveName, filePath, present) {
try {
if (filePath && fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
entries.push({ name: archiveName, data: fs.readFileSync(filePath), mode: 0o600 });
present.push(archiveName);
return true;
}
} catch (_) { /* unreadable — skip */ }
return false;
}
/**
* Recursively collect files under a directory into archive entries.
*/
function addDirIfExists(entries, archivePrefix, dirPath, present) {
try {
if (!dirPath || !fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) return;
const walk = (rel) => {
const abs = path.join(dirPath, rel);
for (const name of fs.readdirSync(abs)) {
const childRel = rel ? path.join(rel, name) : name;
const childAbs = path.join(dirPath, childRel);
const st = fs.statSync(childAbs);
if (st.isDirectory()) {
walk(childRel);
} else if (st.isFile()) {
const archiveName = `${archivePrefix}/${childRel.split(path.sep).join('/')}`;
entries.push({ name: archiveName, data: fs.readFileSync(childAbs), mode: 0o644 });
present.push(archiveName);
}
}
};
walk('');
} catch (_) { /* skip */ }
}
/**
* Build the human-readable recovery instructions bundled in the archive.
*/
function buildRecoveryReadme(manifest) {
return [
'BetterDesk Console — Full Disaster-Recovery Backup',
'==================================================',
'',
`Created: ${manifest._created}`,
`Console version: ${manifest._console_version}`,
`Database engine: ${manifest.dbType}`,
`Components: ${manifest.components.join(', ')}`,
'',
'WARNING: This archive contains SECRETS (Ed25519 server keys, .env with',
'SESSION_SECRET / API key / database credentials, user password hashes and',
'2FA secrets). Store it securely and never share it.',
'',
'How to restore on a fresh machine',
'---------------------------------',
'1. Install BetterDesk using the ALL-IN-ONE installer (betterdesk.sh / .ps1).',
'2. Stop the console and Go server services.',
'3. Open the web console Settings page and use "Restore from backup",',
' OR extract this archive manually with: tar -xzf <backup>.tar.gz',
' and copy each component back to its location:',
' console/.env -> web-nodejs/.env',
' console/.session_secret -> <dataDir>/.session_secret',
' console/auth.db -> <dataDir>/auth.db (SQLite backend only)',
' console/uploads/* -> <dataDir>/uploads/',
' goserver/id_ed25519 -> <keysPath>/id_ed25519',
' goserver/id_ed25519.pub -> <keysPath>/id_ed25519.pub',
' goserver/.api_key -> <keysPath>/.api_key',
' goserver/db_v2.sqlite3 -> Go server DB path (SQLite backend only)',
'4. For PostgreSQL backends, the logical dump in console/data.json is',
' re-imported through the console Restore action.',
'5. Restart both services. Clients keep trusting the server because the',
' Ed25519 identity key was preserved.',
''
].join('\n');
}
/**
* Create a full disaster-recovery backup as a gzipped tar archive.
* @returns {Promise<{ buffer: Buffer, filename: string }>}
*/
async function createFullBackup() {
const timestamp = new Date().toISOString();
const paths = resolveBackupPaths();
const entries = [];
const present = [];
// --- Console logical DB dump (portable across engines) ---
let consoleDump = null;
try { consoleDump = await db.dumpAllTables(); } catch (_) { consoleDump = null; }
if (consoleDump) {
entries.push({ name: 'console/data.json', data: JSON.stringify(consoleDump) });
present.push('console/data.json');
}
// --- Go server data (best-effort, informational) ---
let goServer = null;
if (await serverBackend.isBetterDesk()) {
goServer = await fetchGoServerData();
}
if (goServer) {
entries.push({ name: 'goserver/data.json', data: JSON.stringify(goServer) });
present.push('goserver/data.json');
}
// --- Raw secret / identity files ---
addFileIfExists(entries, 'console/.env', paths.env, present);
addFileIfExists(entries, 'console/.session_secret', paths.sessionSecret, present);
addFileIfExists(entries, 'console/auth.db', paths.consoleDb, present);
addDirIfExists(entries, 'console/uploads', paths.uploadsDir, present);
addFileIfExists(entries, 'goserver/id_ed25519', paths.goPrivKey, present);
addFileIfExists(entries, 'goserver/id_ed25519.pub', paths.goPubKey, present);
addFileIfExists(entries, 'goserver/.api_key', paths.goApiKey, present);
addFileIfExists(entries, 'goserver/db_v2.sqlite3', paths.goDb, present);
// --- Manifest + README ---
const manifest = {
_format: FULL_BACKUP_FORMAT,
_version: FULL_BACKUP_VERSION,
_created: timestamp,
_console_version: config.appVersion,
_backend: await serverBackend.getActiveBackend(),
dbType: config.dbType,
components: present
};
entries.unshift({ name: 'manifest.json', data: JSON.stringify(manifest, null, 2) });
entries.push({ name: 'README.txt', data: buildRecoveryReadme(manifest) });
const buffer = archive.createTarGz(entries);
const dateStr = timestamp.slice(0, 19).replace(/[:T]/g, '-');
return { buffer, filename: `betterdesk-backup-${dateStr}.tar.gz` };
}
/**
* Validate a full-backup archive buffer.
* @returns {{ valid: boolean, errors: string[], manifest: Object|null }}
*/
function validateFullBackup(buffer) {
const errors = [];
let files;
try {
files = archive.extractTarGz(buffer);
} catch (err) {
return { valid: false, errors: [`Not a valid gzip/tar archive: ${err.message}`], manifest: null };
}
const manifestRaw = files.get('manifest.json');
if (!manifestRaw) {
return { valid: false, errors: ['Archive is missing manifest.json'], manifest: null };
}
let manifest;
try {
manifest = JSON.parse(manifestRaw.toString('utf8'));
} catch (err) {
return { valid: false, errors: [`manifest.json is not valid JSON: ${err.message}`], manifest: null };
}
if (manifest._format !== FULL_BACKUP_FORMAT) {
errors.push('Archive is not a BetterDesk full backup (wrong _format)');
}
if (typeof manifest._version !== 'number' || manifest._version > FULL_BACKUP_VERSION) {
errors.push(`Unsupported full-backup version: ${manifest._version}`);
}
return { valid: errors.length === 0, errors, manifest, files };
}
/**
* Restore a full disaster-recovery backup.
*
* @param {Buffer} buffer - gzipped tar archive
* @param {Object} options
* @param {boolean} options.restoreDatabase - Re-import logical DB dump (default true)
* @param {boolean} options.restoreEnv - Overwrite .env (default false — needs restart)
* @param {boolean} options.restoreSecrets - Restore session secret + Go keys + API key (default false)
* @param {boolean} options.restoreUploads - Restore branding upload files (default true)
* @param {boolean} options.restoreGoDb - Overwrite Go server raw DB (default false — needs restart)
* @returns {Promise<{ restored: string[], skipped: string[], warnings: string[], requiresRestart: boolean }>}
*/
async function restoreFullBackup(buffer, options = {}) {
const {
restoreDatabase = true,
restoreEnv = false,
restoreSecrets = false,
restoreUploads = true,
restoreGoDb = false
} = options;
const result = { restored: [], skipped: [], warnings: [], requiresRestart: false };
const validation = validateFullBackup(buffer);
if (!validation.valid) {
throw new Error(`Invalid backup archive: ${validation.errors.join('; ')}`);
}
const files = validation.files;
const paths = resolveBackupPaths();
const writeFile = (filePath, data, mode) => {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, data, { mode: mode || 0o600 });
};
// --- Logical DB import (live restore, no restart needed) ---
if (restoreDatabase && files.has('console/data.json')) {
try {
const dump = JSON.parse(files.get('console/data.json').toString('utf8'));
const r = await db.importAllTables(dump);
result.restored.push(`database (${r.restored.length} tables)`);
for (const w of r.warnings) result.warnings.push(w);
} catch (err) {
result.warnings.push(`Database restore failed: ${err.message}`);
}
} else {
result.skipped.push('database');
}
// --- Branding upload files ---
if (restoreUploads) {
try {
let count = 0;
for (const [name, data] of files) {
if (name.startsWith('console/uploads/')) {
const rel = name.slice('console/uploads/'.length);
if (!rel || rel.includes('..')) continue;
writeFile(path.join(paths.uploadsDir, rel), data, 0o644);
count++;
}
}
if (count > 0) result.restored.push(`uploads (${count} files)`);
else result.skipped.push('uploads');
} catch (err) {
result.warnings.push(`Uploads restore failed: ${err.message}`);
}
} else {
result.skipped.push('uploads');
}
// --- Secrets: session secret, Go identity keys, API key (needs restart) ---
if (restoreSecrets) {
const secretFiles = [
['console/.session_secret', paths.sessionSecret, 0o600],
['goserver/id_ed25519', paths.goPrivKey, 0o600],
['goserver/id_ed25519.pub', paths.goPubKey, 0o644],
['goserver/.api_key', paths.goApiKey, 0o600]
];
let restoredAny = false;
for (const [archiveName, target, mode] of secretFiles) {
if (files.has(archiveName) && target) {
try {
writeFile(target, files.get(archiveName), mode);
restoredAny = true;
} catch (err) {
result.warnings.push(`${archiveName} restore failed: ${err.message}`);
}
}
}
if (restoredAny) { result.restored.push('secrets/keys'); result.requiresRestart = true; }
else result.skipped.push('secrets/keys');
} else {
result.skipped.push('secrets/keys');
}
// --- .env (needs restart) ---
if (restoreEnv && files.has('console/.env')) {
try {
writeFile(paths.env, files.get('console/.env'), 0o600);
result.restored.push('.env');
result.requiresRestart = true;
} catch (err) {
result.warnings.push(`.env restore failed: ${err.message}`);
}
} else {
result.skipped.push('.env');
}
// --- Go server raw DB (SQLite only, needs restart) ---
if (restoreGoDb && files.has('goserver/db_v2.sqlite3') && paths.goDb) {
try {
writeFile(paths.goDb, files.get('goserver/db_v2.sqlite3'), 0o600);
result.restored.push('goserver-db');
result.requiresRestart = true;
} catch (err) {
result.warnings.push(`Go server DB restore failed: ${err.message}`);
}
} else {
result.skipped.push('goserver-db');
}
return result;
}
/**
* Stats / inventory for the full backup UI.
*/
async function getFullBackupStats() {
const stats = await db.getBackupStats();
const paths = resolveBackupPaths();
const exists = (p) => {
try { return !!p && fs.existsSync(p); } catch (_) { return false; }
};
let uploadCount = 0;
try {
if (exists(paths.uploadsDir)) {
const walk = (dir) => {
for (const name of fs.readdirSync(dir)) {
const abs = path.join(dir, name);
const st = fs.statSync(abs);
if (st.isDirectory()) walk(abs);
else if (st.isFile()) uploadCount++;
}
};
walk(paths.uploadsDir);
}
} catch (_) { /* ignore */ }
return {
...stats,
backend: await serverBackend.getActiveBackend(),
dbType: config.dbType,
components: {
env: exists(paths.env),
sessionSecret: exists(paths.sessionSecret),
consoleDb: exists(paths.consoleDb),
uploads: uploadCount,
goPrivKey: exists(paths.goPrivKey),
goPubKey: exists(paths.goPubKey),
goApiKey: exists(paths.goApiKey),
goDb: exists(paths.goDb)
}
};
}
module.exports = {
createBackup,
validateBackup,
restoreBackup,
getBackupStats,
BACKUP_FORMAT_VERSION,
createFullBackup,
restoreFullBackup,
getFullBackupStats,
validateFullBackup,
FULL_BACKUP_FORMAT,
FULL_BACKUP_VERSION
};