Files
cloudron-box/src/backups.js
T

346 lines
14 KiB
JavaScript
Raw Normal View History

'use strict';
exports = module.exports = {
2021-01-21 11:31:35 -08:00
get,
2021-07-14 11:07:19 -07:00
getByIdentifierAndStatePaged,
getByTypePaged,
add,
update,
list,
del,
2015-09-21 14:14:21 -07:00
2021-01-21 11:31:35 -08:00
startBackupTask,
2016-04-10 19:17:44 -07:00
2021-01-21 11:31:35 -08:00
startCleanupTask,
cleanupCacheFilesSync,
2021-01-21 11:31:35 -08:00
injectPrivateFields,
removePrivateFields,
2019-02-09 18:08:10 -08:00
2021-01-21 11:31:35 -08:00
configureCollectd,
2020-01-31 13:37:07 -08:00
2021-01-21 11:31:35 -08:00
generateEncryptionKeysSync,
2021-07-14 11:07:19 -07:00
getSnapshotInfo,
setSnapshotInfo,
testConfig,
testProviderConfig,
2020-05-12 14:00:05 -07:00
remount,
BACKUP_IDENTIFIER_BOX: 'box',
BACKUP_IDENTIFIER_MAIL: 'mail',
BACKUP_TYPE_APP: 'app',
BACKUP_TYPE_BOX: 'box',
BACKUP_TYPE_MAIL: 'mail',
BACKUP_STATE_NORMAL: 'normal', // should rename to created to avoid listing in UI?
BACKUP_STATE_CREATING: 'creating',
BACKUP_STATE_ERROR: 'error',
};
2021-07-14 11:07:19 -07:00
const assert = require('assert'),
2019-10-22 20:36:20 -07:00
BoxError = require('./boxerror.js'),
2020-01-31 13:37:07 -08:00
collectd = require('./collectd.js'),
constants = require('./constants.js'),
CronJob = require('cron').CronJob,
crypto = require('crypto'),
database = require('./database.js'),
2021-09-10 12:10:10 -07:00
debug = require('debug')('box:backups'),
2020-01-31 13:37:07 -08:00
ejs = require('ejs'),
eventlog = require('./eventlog.js'),
2017-09-22 14:40:37 -07:00
fs = require('fs'),
locker = require('./locker.js'),
2016-04-10 19:17:44 -07:00
path = require('path'),
paths = require('./paths.js'),
safe = require('safetydance'),
2015-11-07 22:06:09 -08:00
settings = require('./settings.js'),
2021-07-14 11:07:19 -07:00
storage = require('./storage.js'),
tasks = require('./tasks.js'),
util = require('util');
2021-07-14 11:07:19 -07:00
2020-01-31 13:37:07 -08:00
const COLLECTD_CONFIG_EJS = fs.readFileSync(__dirname + '/collectd/cloudron-backup.ejs', { encoding: 'utf8' });
2018-11-17 19:53:15 -08:00
2021-07-14 11:07:19 -07:00
const BACKUPS_FIELDS = [ 'id', 'identifier', 'creationTime', 'packageVersion', 'type', 'dependsOn', 'state', 'manifestJson', 'format', 'preserveSecs', 'encryptionVersion' ];
2016-04-10 19:17:44 -07:00
// helper until all storage providers have been ported
function maybePromisify(func) {
if (util.types.isAsyncFunction(func)) return func;
return util.promisify(func);
}
2021-07-14 11:07:19 -07:00
function postProcess(result) {
assert.strictEqual(typeof result, 'object');
2016-04-10 19:17:44 -07:00
2021-07-14 11:07:19 -07:00
result.dependsOn = result.dependsOn ? result.dependsOn.split(',') : [ ];
result.manifest = result.manifestJson ? safe.JSON.parse(result.manifestJson) : null;
delete result.manifestJson;
2015-11-06 18:14:59 -08:00
2021-07-14 11:07:19 -07:00
return result;
}
2019-02-09 18:08:10 -08:00
function injectPrivateFields(newConfig, currentConfig) {
2020-05-14 11:18:41 -07:00
if ('password' in newConfig) {
2020-05-14 23:01:44 +02:00
if (newConfig.password === constants.SECRET_PLACEHOLDER) {
2020-05-14 11:18:41 -07:00
delete newConfig.password;
}
2020-05-14 23:35:03 +02:00
newConfig.encryption = currentConfig.encryption || null;
} else {
newConfig.encryption = null;
2020-05-12 14:00:05 -07:00
}
2021-07-14 11:07:19 -07:00
if (newConfig.provider === currentConfig.provider) storage.api(newConfig.provider).injectPrivateFields(newConfig, currentConfig);
2019-02-09 18:08:10 -08:00
}
function removePrivateFields(backupConfig) {
assert.strictEqual(typeof backupConfig, 'object');
2020-05-12 14:00:05 -07:00
if (backupConfig.encryption) {
delete backupConfig.encryption;
2020-05-14 23:01:44 +02:00
backupConfig.password = constants.SECRET_PLACEHOLDER;
2020-05-12 14:00:05 -07:00
}
2021-07-14 11:07:19 -07:00
return storage.api(backupConfig.provider).removePrivateFields(backupConfig);
}
2021-10-06 13:09:04 -07:00
// this function is used in migrations - 20200512172301-settings-backup-encryption.js
2020-05-12 14:00:05 -07:00
function generateEncryptionKeysSync(password) {
assert.strictEqual(typeof password, 'string');
const aesKeys = crypto.scryptSync(password, Buffer.from('CLOUDRONSCRYPTSALT', 'utf8'), 128);
return {
dataKey: aesKeys.slice(0, 32).toString('hex'),
dataHmacKey: aesKeys.slice(32, 64).toString('hex'),
filenameKey: aesKeys.slice(64, 96).toString('hex'),
filenameHmacKey: aesKeys.slice(96).toString('hex')
};
}
2019-12-05 11:55:51 -08:00
2021-07-14 11:07:19 -07:00
async function add(id, data) {
assert(data && typeof data === 'object');
assert.strictEqual(typeof id, 'string');
assert(data.encryptionVersion === null || typeof data.encryptionVersion === 'number');
assert.strictEqual(typeof data.packageVersion, 'string');
assert.strictEqual(typeof data.type, 'string');
assert.strictEqual(typeof data.identifier, 'string');
assert.strictEqual(typeof data.state, 'string');
assert(Array.isArray(data.dependsOn));
assert.strictEqual(typeof data.manifest, 'object');
assert.strictEqual(typeof data.format, 'string');
const creationTime = data.creationTime || new Date(); // allow tests to set the time
const manifestJson = JSON.stringify(data.manifest);
const [error] = await safe(database.query('INSERT INTO backups (id, identifier, encryptionVersion, packageVersion, type, creationTime, state, dependsOn, manifestJson, format) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[ id, data.identifier, data.encryptionVersion, data.packageVersion, data.type, creationTime, data.state, data.dependsOn.join(','), manifestJson, data.format ]));
if (error && error.code === 'ER_DUP_ENTRY') throw new BoxError(BoxError.ALREADY_EXISTS, 'Backup already exists');
if (error) throw error;
}
async function getByIdentifierAndStatePaged(identifier, state, page, perPage) {
assert.strictEqual(typeof identifier, 'string');
assert.strictEqual(typeof state, 'string');
2016-03-08 08:52:20 -08:00
assert(typeof page === 'number' && page > 0);
assert(typeof perPage === 'number' && perPage > 0);
2021-07-14 11:07:19 -07:00
const results = await database.query(`SELECT ${BACKUPS_FIELDS} FROM backups WHERE identifier = ? AND state = ? ORDER BY creationTime DESC LIMIT ?,?`, [ identifier, state, (page-1)*perPage, perPage ]);
2021-07-14 11:07:19 -07:00
results.forEach(function (result) { postProcess(result); });
2021-07-14 11:07:19 -07:00
return results;
}
2021-07-14 11:07:19 -07:00
async function get(id) {
assert.strictEqual(typeof id, 'string');
2017-09-19 20:40:38 -07:00
2021-07-14 11:07:19 -07:00
const result = await database.query('SELECT ' + BACKUPS_FIELDS + ' FROM backups WHERE id = ? ORDER BY creationTime DESC', [ id ]);
if (result.length === 0) return null;
2021-07-14 11:07:19 -07:00
return postProcess(result[0]);
2017-09-19 20:40:38 -07:00
}
2021-07-14 11:07:19 -07:00
async function getByTypePaged(type, page, perPage) {
assert.strictEqual(typeof type, 'string');
assert(typeof page === 'number' && page > 0);
assert(typeof perPage === 'number' && perPage > 0);
2018-07-27 06:55:54 -07:00
2021-07-14 11:07:19 -07:00
const results = await database.query(`SELECT ${BACKUPS_FIELDS} FROM backups WHERE type = ? ORDER BY creationTime DESC LIMIT ?,?`, [ type, (page-1)*perPage, perPage ]);
2018-07-27 06:55:54 -07:00
2021-07-14 11:07:19 -07:00
results.forEach(function (result) { postProcess(result); });
2018-07-27 11:46:42 -07:00
2021-07-14 11:07:19 -07:00
return results;
2018-07-27 11:46:42 -07:00
}
2021-07-14 11:07:19 -07:00
async function update(id, backup) {
assert.strictEqual(typeof id, 'string');
assert.strictEqual(typeof backup, 'object');
2020-05-15 16:05:12 -07:00
2021-07-14 11:07:19 -07:00
let fields = [ ], values = [ ];
for (const p in backup) {
fields.push(p + ' = ?');
values.push(backup[p]);
2020-05-10 21:40:25 -07:00
}
2021-07-14 11:07:19 -07:00
values.push(id);
2020-05-10 21:40:25 -07:00
2021-07-14 11:07:19 -07:00
const result = await database.query('UPDATE backups SET ' + fields.join(', ') + ' WHERE id = ?', values);
if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Backup not found');
2020-05-10 21:40:25 -07:00
}
2021-09-10 12:10:10 -07:00
async function startBackupTask(auditSource) {
2021-07-14 11:07:19 -07:00
let error = locker.lock(locker.OP_FULL_BACKUP);
2021-09-10 12:10:10 -07:00
if (error) throw new BoxError(BoxError.BAD_STATE, `Cannot backup now: ${error.message}`);
2020-05-10 21:40:25 -07:00
2021-09-10 12:10:10 -07:00
const backupConfig = await settings.getBackupConfig();
2018-07-27 06:55:54 -07:00
2021-09-10 12:10:10 -07:00
const memoryLimit = 'memoryLimit' in backupConfig ? Math.max(backupConfig.memoryLimit/1024/1024, 400) : 400;
2018-07-27 06:55:54 -07:00
2021-09-10 12:10:10 -07:00
const taskId = await tasks.add(tasks.TASK_BACKUP, [ { /* options */ } ]);
2018-07-27 06:55:54 -07:00
2021-09-10 12:10:10 -07:00
await eventlog.add(eventlog.ACTION_BACKUP_START, auditSource, { taskId });
2021-09-10 12:10:10 -07:00
tasks.startTask(taskId, { timeout: 24 * 60 * 60 * 1000 /* 24 hours */, nice: 15, memoryLimit }, async function (error, backupId) {
locker.unlock(locker.OP_FULL_BACKUP);
2020-05-10 21:40:25 -07:00
2021-09-10 12:10:10 -07:00
const errorMessage = error ? error.message : '';
const timedOut = error ? error.code === tasks.ETIMEOUT : false;
2020-05-10 21:40:25 -07:00
2021-09-10 12:10:10 -07:00
await safe(eventlog.add(eventlog.ACTION_BACKUP_FINISH, auditSource, { taskId, errorMessage, timedOut, backupId }), { debug });
});
2021-09-10 12:10:10 -07:00
return taskId;
2021-07-14 11:07:19 -07:00
}
2018-07-27 11:46:42 -07:00
2021-07-14 11:07:19 -07:00
async function list(page, perPage) {
assert(typeof page === 'number' && page > 0);
assert(typeof perPage === 'number' && perPage > 0);
2021-07-14 11:07:19 -07:00
const results = await database.query('SELECT ' + BACKUPS_FIELDS + ' FROM backups ORDER BY creationTime DESC LIMIT ?,?', [ (page-1)*perPage, perPage ]);
2020-05-10 21:40:25 -07:00
2021-07-14 11:07:19 -07:00
results.forEach(function (result) { postProcess(result); });
2021-07-14 11:07:19 -07:00
return results;
2018-07-27 11:46:42 -07:00
}
2021-07-14 11:07:19 -07:00
async function del(id) {
assert.strictEqual(typeof id, 'string');
2021-07-14 11:07:19 -07:00
const result = await database.query('DELETE FROM backups WHERE id=?', [ id ]);
if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Backup not found');
}
2021-10-06 13:09:04 -07:00
// this function is used in migrations - 20200512172301-settings-backup-encryption.js
2021-07-14 11:07:19 -07:00
function cleanupCacheFilesSync() {
2021-09-10 12:10:10 -07:00
const files = safe.fs.readdirSync(path.join(paths.BACKUP_INFO_DIR));
2021-07-14 11:07:19 -07:00
if (!files) return;
2021-09-10 12:10:10 -07:00
files
.filter(function (f) { return f.endsWith('.sync.cache'); })
.forEach(function (f) {
safe.fs.unlinkSync(path.join(paths.BACKUP_INFO_DIR, f));
});
2020-05-10 21:40:25 -07:00
}
2021-07-14 11:07:19 -07:00
function getSnapshotInfo(id) {
assert.strictEqual(typeof id, 'string');
2021-07-14 11:07:19 -07:00
const contents = safe.fs.readFileSync(paths.SNAPSHOT_INFO_FILE, 'utf8');
const info = safe.JSON.parse(contents);
if (!info) return { };
return info[id] || { };
2017-09-22 14:40:37 -07:00
}
// keeps track of contents of the snapshot directory. this provides a way to clean up backups of uninstalled apps
2021-09-16 13:59:03 -07:00
async function setSnapshotInfo(id, info) {
2021-07-14 11:07:19 -07:00
assert.strictEqual(typeof id, 'string');
assert.strictEqual(typeof info, 'object');
2017-09-22 14:40:37 -07:00
2021-07-14 11:07:19 -07:00
const contents = safe.fs.readFileSync(paths.SNAPSHOT_INFO_FILE, 'utf8');
const data = safe.JSON.parse(contents) || { };
if (info) data[id] = info; else delete data[id];
if (!safe.fs.writeFileSync(paths.SNAPSHOT_INFO_FILE, JSON.stringify(data, null, 4), 'utf8')) {
2021-09-16 13:59:03 -07:00
throw new BoxError(BoxError.FS_ERROR, safe.error.message);
2019-01-17 09:53:51 -08:00
}
2017-09-22 14:40:37 -07:00
}
2021-07-14 11:07:19 -07:00
async function startCleanupTask(auditSource) {
assert.strictEqual(typeof auditSource, 'object');
2017-09-19 20:27:36 -07:00
2021-07-14 11:07:19 -07:00
const taskId = await tasks.add(tasks.TASK_CLEAN_BACKUPS, []);
2017-09-19 20:27:36 -07:00
2021-07-14 11:07:19 -07:00
tasks.startTask(taskId, {}, (error, result) => { // result is { removedBoxBackupIds, removedAppBackupIds, missingBackupIds }
eventlog.add(eventlog.ACTION_BACKUP_CLEANUP_FINISH, auditSource, {
taskId,
errorMessage: error ? error.message : null,
removedBoxBackupIds: result ? result.removedBoxBackupIds : [],
removedAppBackupIds: result ? result.removedAppBackupIds : [],
missingBackupIds: result ? result.missingBackupIds : []
});
});
2021-07-14 11:07:19 -07:00
return taskId;
2017-09-23 14:27:35 -07:00
}
2021-08-19 13:24:38 -07:00
async function configureCollectd(backupConfig) {
2018-07-27 15:22:54 -07:00
assert.strictEqual(typeof backupConfig, 'object');
2021-07-14 11:07:19 -07:00
if (backupConfig.provider === 'filesystem') {
const collectdConf = ejs.render(COLLECTD_CONFIG_EJS, { backupDir: backupConfig.backupFolder });
2021-08-19 13:24:38 -07:00
await collectd.addProfile('cloudron-backup', collectdConf);
2021-07-14 11:07:19 -07:00
} else {
2021-08-19 13:24:38 -07:00
await collectd.removeProfile('cloudron-backup');
2018-07-27 15:22:54 -07:00
}
}
async function testConfig(backupConfig) {
2017-11-22 10:29:40 -08:00
assert.strictEqual(typeof backupConfig, 'object');
2021-07-14 11:07:19 -07:00
const func = storage.api(backupConfig.provider);
if (!func) return new BoxError(BoxError.BAD_FIELD, 'unknown storage provider', { field: 'provider' });
if (backupConfig.format !== 'tgz' && backupConfig.format !== 'rsync') return new BoxError(BoxError.BAD_FIELD, 'unknown format', { field: 'format' });
2019-01-14 11:36:11 -08:00
2021-07-14 11:07:19 -07:00
const job = safe.safeCall(function () { return new CronJob(backupConfig.schedulePattern); });
if (!job) return new BoxError(BoxError.BAD_FIELD, 'Invalid schedule pattern', { field: 'schedulePattern' });
2021-07-14 11:07:19 -07:00
if ('password' in backupConfig) {
if (typeof backupConfig.password !== 'string') return new BoxError(BoxError.BAD_FIELD, 'password must be a string', { field: 'password' });
if (backupConfig.password.length < 8) return new BoxError(BoxError.BAD_FIELD, 'password must be atleast 8 characters', { field: 'password' });
2021-07-14 11:07:19 -07:00
}
2021-07-14 11:07:19 -07:00
const policy = backupConfig.retentionPolicy;
if (!policy) return new BoxError(BoxError.BAD_FIELD, 'retentionPolicy is required', { field: 'retentionPolicy' });
if (!['keepWithinSecs','keepDaily','keepWeekly','keepMonthly','keepYearly'].find(k => !!policy[k])) return new BoxError(BoxError.BAD_FIELD, 'properties missing', { field: 'retentionPolicy' });
if ('keepWithinSecs' in policy && typeof policy.keepWithinSecs !== 'number') return new BoxError(BoxError.BAD_FIELD, 'keepWithinSecs must be a number', { field: 'retentionPolicy' });
if ('keepDaily' in policy && typeof policy.keepDaily !== 'number') return new BoxError(BoxError.BAD_FIELD, 'keepDaily must be a number', { field: 'retentionPolicy' });
if ('keepWeekly' in policy && typeof policy.keepWeekly !== 'number') return new BoxError(BoxError.BAD_FIELD, 'keepWeekly must be a number', { field: 'retentionPolicy' });
if ('keepMonthly' in policy && typeof policy.keepMonthly !== 'number') return new BoxError(BoxError.BAD_FIELD, 'keepMonthly must be a number', { field: 'retentionPolicy' });
if ('keepYearly' in policy && typeof policy.keepYearly !== 'number') return new BoxError(BoxError.BAD_FIELD, 'keepYearly must be a number', { field: 'retentionPolicy' });
const [error] = await safe(util.promisify(storage.api(backupConfig.provider).testConfig)(backupConfig));
return error;
2017-09-19 20:27:36 -07:00
}
2017-10-10 20:23:04 -07:00
2021-07-14 11:07:19 -07:00
// this skips password check since that policy is only at creation time
async function testProviderConfig(backupConfig) {
assert.strictEqual(typeof backupConfig, 'object');
2021-07-14 11:07:19 -07:00
const func = storage.api(backupConfig.provider);
if (!func) return new BoxError(BoxError.BAD_FIELD, 'unknown storage provider', { field: 'provider' });
2018-09-26 12:39:33 -07:00
const [error] = await safe(util.promisify(storage.api(backupConfig.provider).testConfig)(backupConfig));
return error;
2020-02-26 09:08:30 -08:00
}
async function remount(auditSource) {
assert.strictEqual(typeof auditSource, 'object');
const backupConfig = await settings.getBackupConfig();
const func = storage.api(backupConfig.provider);
if (!func) throw new BoxError(BoxError.BAD_FIELD, 'unknown storage provider', { field: 'provider' });
const [error] = await safe(maybePromisify(storage.api(backupConfig.provider).remount)(backupConfig));
return error;
}