1073 lines
46 KiB
JavaScript
1073 lines
46 KiB
JavaScript
'use strict';
|
|
|
|
exports = module.exports = {
|
|
fullBackup,
|
|
|
|
restore,
|
|
|
|
backupApp,
|
|
downloadApp,
|
|
|
|
backupMail,
|
|
downloadMail,
|
|
|
|
upload,
|
|
|
|
_restoreFsMetadata: restoreFsMetadata,
|
|
_saveFsMetadata: saveFsMetadata,
|
|
};
|
|
|
|
const apps = require('./apps.js'),
|
|
assert = require('assert'),
|
|
async = require('async'),
|
|
backups = require('./backups.js'),
|
|
BoxError = require('./boxerror.js'),
|
|
constants = require('./constants.js'),
|
|
crypto = require('crypto'),
|
|
DataLayout = require('./datalayout.js'),
|
|
database = require('./database.js'),
|
|
debug = require('debug')('box:backuptask'),
|
|
fs = require('fs'),
|
|
once = require('./once.js'),
|
|
path = require('path'),
|
|
paths = require('./paths.js'),
|
|
progressStream = require('progress-stream'),
|
|
safe = require('safetydance'),
|
|
services = require('./services.js'),
|
|
settings = require('./settings.js'),
|
|
shell = require('./shell.js'),
|
|
storage = require('./storage.js'),
|
|
syncer = require('./syncer.js'),
|
|
tar = require('tar-fs'),
|
|
TransformStream = require('stream').Transform,
|
|
zlib = require('zlib'),
|
|
util = require('util');
|
|
|
|
const BACKUP_UPLOAD_CMD = path.join(__dirname, 'scripts/backupupload.js');
|
|
const getBackupConfig = util.callbackify(settings.getBackupConfig);
|
|
const runBackupUploadAsync = util.promisify(runBackupUpload);
|
|
|
|
function canBackupApp(app) {
|
|
// only backup apps that are installed or specific pending states
|
|
|
|
// stopped apps cannot be backed up because addons might be down (redis)
|
|
if (app.runState === apps.RSTATE_STOPPED) return false;
|
|
|
|
// we used to check the health here but that doesn't work for stopped apps. it's better to just fail
|
|
// and inform the user if the backup fails and the app addons have not been setup yet.
|
|
return app.installationState === apps.ISTATE_INSTALLED ||
|
|
app.installationState === apps.ISTATE_PENDING_CONFIGURE ||
|
|
app.installationState === apps.ISTATE_PENDING_BACKUP || // called from apptask
|
|
app.installationState === apps.ISTATE_PENDING_UPDATE; // called from apptask
|
|
}
|
|
|
|
function encryptFilePath(filePath, encryption) {
|
|
assert.strictEqual(typeof filePath, 'string');
|
|
assert.strictEqual(typeof encryption, 'object');
|
|
|
|
const encryptedParts = filePath.split('/').map(function (part) {
|
|
let hmac = crypto.createHmac('sha256', Buffer.from(encryption.filenameHmacKey, 'hex'));
|
|
const iv = hmac.update(part).digest().slice(0, 16); // iv has to be deterministic, for our sync (copy) logic to work
|
|
const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(encryption.filenameKey, 'hex'), iv);
|
|
let crypt = cipher.update(part);
|
|
crypt = Buffer.concat([ iv, crypt, cipher.final() ]);
|
|
|
|
return crypt.toString('base64') // ensures path is valid
|
|
.replace(/\//g, '-') // replace '/' of base64 since it conflicts with path separator
|
|
.replace(/=/g,''); // strip trailing = padding. this is only needed if we concat base64 strings, which we don't
|
|
});
|
|
|
|
return encryptedParts.join('/');
|
|
}
|
|
|
|
function decryptFilePath(filePath, encryption) {
|
|
assert.strictEqual(typeof filePath, 'string');
|
|
assert.strictEqual(typeof encryption, 'object');
|
|
|
|
const decryptedParts = [];
|
|
for (let part of filePath.split('/')) {
|
|
part = part + Array(part.length % 4).join('='); // add back = padding
|
|
part = part.replace(/-/g, '/'); // replace with '/'
|
|
|
|
try {
|
|
const buffer = Buffer.from(part, 'base64');
|
|
const iv = buffer.slice(0, 16);
|
|
let decrypt = crypto.createDecipheriv('aes-256-cbc', Buffer.from(encryption.filenameKey, 'hex'), iv);
|
|
const plainText = decrypt.update(buffer.slice(16));
|
|
const plainTextString = Buffer.concat([ plainText, decrypt.final() ]).toString('utf8');
|
|
const hmac = crypto.createHmac('sha256', Buffer.from(encryption.filenameHmacKey, 'hex'));
|
|
if (!hmac.update(plainTextString).digest().slice(0, 16).equals(iv)) return { error: new BoxError(BoxError.CRYPTO_ERROR, `mac error decrypting part ${part} of path ${filePath}`) };
|
|
|
|
decryptedParts.push(plainTextString);
|
|
} catch (error) {
|
|
debug(`Error decrypting part ${part} of path ${filePath}:`, error);
|
|
return { error: new BoxError(BoxError.CRYPTO_ERROR, `Error decrypting part ${part} of path ${filePath}: ${error.message}`) };
|
|
}
|
|
}
|
|
|
|
return { result: decryptedParts.join('/') };
|
|
}
|
|
|
|
class EncryptStream extends TransformStream {
|
|
constructor(encryption) {
|
|
super();
|
|
this._headerPushed = false;
|
|
this._iv = crypto.randomBytes(16);
|
|
this._cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(encryption.dataKey, 'hex'), this._iv);
|
|
this._hmac = crypto.createHmac('sha256', Buffer.from(encryption.dataHmacKey, 'hex'));
|
|
}
|
|
|
|
pushHeaderIfNeeded() {
|
|
if (!this._headerPushed) {
|
|
const magic = Buffer.from('CBV2');
|
|
this.push(magic);
|
|
this._hmac.update(magic);
|
|
this.push(this._iv);
|
|
this._hmac.update(this._iv);
|
|
this._headerPushed = true;
|
|
}
|
|
}
|
|
|
|
_transform(chunk, ignoredEncoding, callback) {
|
|
this.pushHeaderIfNeeded();
|
|
|
|
try {
|
|
const crypt = this._cipher.update(chunk);
|
|
this._hmac.update(crypt);
|
|
callback(null, crypt);
|
|
} catch (error) {
|
|
callback(error);
|
|
}
|
|
}
|
|
|
|
_flush(callback) {
|
|
try {
|
|
this.pushHeaderIfNeeded(); // for 0-length files
|
|
const crypt = this._cipher.final();
|
|
this.push(crypt);
|
|
this._hmac.update(crypt);
|
|
callback(null, this._hmac.digest()); // +32 bytes
|
|
} catch (error) {
|
|
callback(error);
|
|
}
|
|
}
|
|
}
|
|
|
|
class DecryptStream extends TransformStream {
|
|
constructor(encryption) {
|
|
super();
|
|
this._key = Buffer.from(encryption.dataKey, 'hex');
|
|
this._header = Buffer.alloc(0);
|
|
this._decipher = null;
|
|
this._hmac = crypto.createHmac('sha256', Buffer.from(encryption.dataHmacKey, 'hex'));
|
|
this._buffer = Buffer.alloc(0);
|
|
}
|
|
|
|
_transform(chunk, ignoredEncoding, callback) {
|
|
const needed = 20 - this._header.length; // 4 for magic, 16 for iv
|
|
|
|
if (this._header.length !== 20) { // not gotten header yet
|
|
this._header = Buffer.concat([this._header, chunk.slice(0, needed)]);
|
|
if (this._header.length !== 20) return callback();
|
|
|
|
if (!this._header.slice(0, 4).equals(new Buffer.from('CBV2'))) return callback(new BoxError(BoxError.CRYPTO_ERROR, 'Invalid magic in header'));
|
|
|
|
const iv = this._header.slice(4);
|
|
this._decipher = crypto.createDecipheriv('aes-256-cbc', this._key, iv);
|
|
this._hmac.update(this._header);
|
|
}
|
|
|
|
this._buffer = Buffer.concat([ this._buffer, chunk.slice(needed) ]);
|
|
if (this._buffer.length < 32) return callback(); // hmac trailer length is 32
|
|
|
|
try {
|
|
const cipherText = this._buffer.slice(0, -32);
|
|
this._hmac.update(cipherText);
|
|
const plainText = this._decipher.update(cipherText);
|
|
this._buffer = this._buffer.slice(-32);
|
|
callback(null, plainText);
|
|
} catch (error) {
|
|
callback(error);
|
|
}
|
|
}
|
|
|
|
_flush (callback) {
|
|
if (this._buffer.length !== 32) return callback(new BoxError(BoxError.CRYPTO_ERROR, 'Invalid password or tampered file (not enough data)'));
|
|
|
|
try {
|
|
if (!this._hmac.digest().equals(this._buffer)) return callback(new BoxError(BoxError.CRYPTO_ERROR, 'Invalid password or tampered file (mac mismatch)'));
|
|
|
|
const plainText = this._decipher.final();
|
|
callback(null, plainText);
|
|
} catch (error) {
|
|
callback(error);
|
|
}
|
|
}
|
|
}
|
|
|
|
function createReadStream(sourceFile, encryption) {
|
|
assert.strictEqual(typeof sourceFile, 'string');
|
|
assert.strictEqual(typeof encryption, 'object');
|
|
|
|
const stream = fs.createReadStream(sourceFile);
|
|
const ps = progressStream({ time: 10000 }); // display a progress every 10 seconds
|
|
|
|
stream.on('error', function (error) {
|
|
debug(`createReadStream: read stream error at ${sourceFile}`, error);
|
|
ps.emit('error', new BoxError(BoxError.FS_ERROR, `Error reading ${sourceFile}: ${error.message} ${error.code}`));
|
|
});
|
|
|
|
stream.on('open', () => ps.emit('open'));
|
|
|
|
if (encryption) {
|
|
let encryptStream = new EncryptStream(encryption);
|
|
|
|
encryptStream.on('error', function (error) {
|
|
debug(`createReadStream: encrypt stream error ${sourceFile}`, error);
|
|
ps.emit('error', new BoxError(BoxError.CRYPTO_ERROR, `Encryption error at ${sourceFile}: ${error.message}`));
|
|
});
|
|
|
|
return stream.pipe(encryptStream).pipe(ps);
|
|
} else {
|
|
return stream.pipe(ps);
|
|
}
|
|
}
|
|
|
|
function createWriteStream(destFile, encryption) {
|
|
assert.strictEqual(typeof destFile, 'string');
|
|
assert.strictEqual(typeof encryption, 'object');
|
|
|
|
const stream = fs.createWriteStream(destFile);
|
|
const ps = progressStream({ time: 10000 }); // display a progress every 10 seconds
|
|
|
|
stream.on('error', function (error) {
|
|
debug(`createWriteStream: write stream error ${destFile}`, error);
|
|
ps.emit('error', new BoxError(BoxError.FS_ERROR, `Write error ${destFile}: ${error.message}`));
|
|
});
|
|
|
|
stream.on('finish', function () {
|
|
debug('createWriteStream: done.');
|
|
// we use a separate event because ps is a through2 stream which emits 'finish' event indicating end of inStream and not write
|
|
ps.emit('done');
|
|
});
|
|
|
|
if (encryption) {
|
|
let decrypt = new DecryptStream(encryption);
|
|
decrypt.on('error', function (error) {
|
|
debug(`createWriteStream: decrypt stream error ${destFile}`, error);
|
|
ps.emit('error', new BoxError(BoxError.CRYPTO_ERROR, `Decryption error at ${destFile}: ${error.message}`));
|
|
});
|
|
|
|
ps.pipe(decrypt).pipe(stream);
|
|
} else {
|
|
ps.pipe(stream);
|
|
}
|
|
|
|
return ps;
|
|
}
|
|
|
|
function tarPack(dataLayout, encryption, callback) {
|
|
assert(dataLayout instanceof DataLayout, 'dataLayout must be a DataLayout');
|
|
assert.strictEqual(typeof encryption, 'object');
|
|
assert.strictEqual(typeof callback, 'function');
|
|
|
|
const pack = tar.pack('/', {
|
|
dereference: false, // pack the symlink and not what it points to
|
|
entries: dataLayout.localPaths(),
|
|
ignoreStatError: (path, err) => {
|
|
debug(`tarPack: error stat'ing ${path} - ${err.code}`);
|
|
return err.code === 'ENOENT'; // ignore if file or dir got removed (probably some temporary file)
|
|
},
|
|
map: function(header) {
|
|
header.name = dataLayout.toRemotePath(header.name);
|
|
// the tar pax format allows us to encode filenames > 100 and size > 8GB (see #640)
|
|
// https://www.systutorials.com/docs/linux/man/5-star/
|
|
if (header.size > 8589934590 || header.name > 99) header.pax = { size: header.size };
|
|
return header;
|
|
},
|
|
strict: false // do not error for unknown types (skip fifo, char/block devices)
|
|
});
|
|
|
|
const gzip = zlib.createGzip({});
|
|
const ps = progressStream({ time: 10000 }); // emit 'progress' every 10 seconds
|
|
|
|
pack.on('error', function (error) {
|
|
debug('tarPack: tar stream error.', error);
|
|
ps.emit('error', new BoxError(BoxError.EXTERNAL_ERROR, error.message));
|
|
});
|
|
|
|
gzip.on('error', function (error) {
|
|
debug('tarPack: gzip stream error.', error);
|
|
ps.emit('error', new BoxError(BoxError.EXTERNAL_ERROR, error.message));
|
|
});
|
|
|
|
if (encryption) {
|
|
const encryptStream = new EncryptStream(encryption);
|
|
encryptStream.on('error', function (error) {
|
|
debug('tarPack: encrypt stream error.', error);
|
|
ps.emit('error', new BoxError(BoxError.EXTERNAL_ERROR, error.message));
|
|
});
|
|
|
|
pack.pipe(gzip).pipe(encryptStream).pipe(ps);
|
|
} else {
|
|
pack.pipe(gzip).pipe(ps);
|
|
}
|
|
|
|
return callback(null, ps);
|
|
}
|
|
|
|
function sync(backupConfig, remotePath, dataLayout, progressCallback, callback) {
|
|
assert.strictEqual(typeof backupConfig, 'object');
|
|
assert.strictEqual(typeof remotePath, 'string');
|
|
assert(dataLayout instanceof DataLayout, 'dataLayout must be a DataLayout');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
assert.strictEqual(typeof callback, 'function');
|
|
|
|
// the number here has to take into account the s3.upload partSize (which is 10MB). So 20=200MB
|
|
const concurrency = backupConfig.syncConcurrency || (backupConfig.provider === 's3' ? 20 : 10);
|
|
|
|
syncer.sync(dataLayout, async function processTask(task, iteratorCallback) {
|
|
debug('sync: processing task: %j', task);
|
|
// the empty task.path is special to signify the directory
|
|
const destPath = task.path && backupConfig.encryption ? encryptFilePath(task.path, backupConfig.encryption) : task.path;
|
|
const backupFilePath = path.join(storage.getBackupFilePath(backupConfig, remotePath, backupConfig.format), destPath);
|
|
|
|
if (task.operation === 'removedir') {
|
|
debug(`Removing directory ${backupFilePath}`);
|
|
const [error] = await safe(storage.api(backupConfig.provider).removeDir(backupConfig, backupFilePath, progressCallback));
|
|
return iteratorCallback(error);
|
|
} else if (task.operation === 'remove') {
|
|
debug(`Removing ${backupFilePath}`);
|
|
const [error] = await safe(storage.api(backupConfig.provider).remove(backupConfig, backupFilePath));
|
|
return iteratorCallback(error);
|
|
}
|
|
|
|
let retryCount = 0;
|
|
async.retry({ times: 5, interval: 20000 }, function (retryCallback) {
|
|
retryCallback = once(retryCallback); // protect again upload() erroring much later after read stream error
|
|
|
|
++retryCount;
|
|
if (task.operation === 'add') {
|
|
progressCallback({ message: `Adding ${task.path}` + (retryCount > 1 ? ` (Try ${retryCount})` : '') });
|
|
debug(`Adding ${task.path} position ${task.position} try ${retryCount}`);
|
|
const stream = createReadStream(dataLayout.toLocalPath('./' + task.path), backupConfig.encryption);
|
|
stream.on('error', (error) => retryCallback(error.message.includes('ENOENT') ? null : error)); // ignore error if file disappears
|
|
stream.on('progress', function (progress) {
|
|
const transferred = Math.round(progress.transferred/1024/1024), speed = Math.round(progress.speed/1024/1024);
|
|
if (!transferred && !speed) return progressCallback({ message: `Uploading ${task.path}` }); // 0M@0MBps looks wrong
|
|
progressCallback({ message: `Uploading ${task.path}: ${transferred}M@${speed}MBps` }); // 0M@0MBps looks wrong
|
|
});
|
|
// only create the destination path when we have confirmation that the source is available. otherwise, we end up with
|
|
// files owned as 'root' and the cp later will fail
|
|
stream.on('open', function () {
|
|
storage.api(backupConfig.provider).upload(backupConfig, backupFilePath, stream, function (error) {
|
|
debug(error ? `Error uploading ${task.path} try ${retryCount}: ${error.message}` : `Uploaded ${task.path}`);
|
|
retryCallback(error);
|
|
});
|
|
});
|
|
}
|
|
}, iteratorCallback);
|
|
}, concurrency, function (error) {
|
|
if (error) return callback(new BoxError(BoxError.EXTERNAL_ERROR, error.message));
|
|
|
|
callback();
|
|
});
|
|
}
|
|
|
|
// this is not part of 'snapshotting' because we need root access to traverse
|
|
async function saveFsMetadata(dataLayout, metadataFile) {
|
|
assert(dataLayout instanceof DataLayout, 'dataLayout must be a DataLayout');
|
|
assert.strictEqual(typeof metadataFile, 'string');
|
|
|
|
// contains paths prefixed with './'
|
|
const metadata = {
|
|
emptyDirs: [],
|
|
execFiles: [],
|
|
symlinks: []
|
|
};
|
|
|
|
// we assume small number of files. spawnSync will raise a ENOBUFS error after maxBuffer
|
|
for (let lp of dataLayout.localPaths()) {
|
|
const emptyDirs = safe.child_process.execSync(`find ${lp} -type d -empty`, { encoding: 'utf8', maxBuffer: 1024 * 1024 * 30 });
|
|
if (emptyDirs === null) throw new BoxError(BoxError.FS_ERROR, `Error finding empty dirs: ${safe.error.message}`);
|
|
if (emptyDirs.length) metadata.emptyDirs = metadata.emptyDirs.concat(emptyDirs.trim().split('\n').map((ed) => dataLayout.toRemotePath(ed)));
|
|
|
|
const execFiles = safe.child_process.execSync(`find ${lp} -type f -executable`, { encoding: 'utf8', maxBuffer: 1024 * 1024 * 30 });
|
|
if (execFiles === null) throw new BoxError(BoxError.FS_ERROR, `Error finding executables: ${safe.error.message}`);
|
|
if (execFiles.length) metadata.execFiles = metadata.execFiles.concat(execFiles.trim().split('\n').map((ef) => dataLayout.toRemotePath(ef)));
|
|
|
|
const symlinks = safe.child_process.execSync(`find ${lp} -type l`, { encoding: 'utf8', maxBuffer: 1024 * 1024 * 30 });
|
|
if (symlinks === null) throw new BoxError(BoxError.FS_ERROR, `Error finding symlinks: ${safe.error.message}`);
|
|
if (symlinks.length) metadata.symlinks = metadata.symlinks.concat(symlinks.trim().split('\n').map((sl) => {
|
|
const target = safe.fs.readlinkSync(sl);
|
|
return { path: dataLayout.toRemotePath(sl), target };
|
|
}));
|
|
}
|
|
|
|
if (!safe.fs.writeFileSync(metadataFile, JSON.stringify(metadata, null, 4))) throw new BoxError(BoxError.FS_ERROR, `Error writing fs metadata: ${safe.error.message}`);
|
|
}
|
|
|
|
// this function is called via backupupload (since it needs root to traverse app's directory)
|
|
function upload(remotePath, format, dataLayoutString, progressCallback, callback) {
|
|
assert.strictEqual(typeof remotePath, 'string');
|
|
assert.strictEqual(typeof format, 'string');
|
|
assert.strictEqual(typeof dataLayoutString, 'string');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
assert.strictEqual(typeof callback, 'function');
|
|
|
|
debug(`upload: path ${remotePath} format ${format} dataLayout ${dataLayoutString}`);
|
|
|
|
const dataLayout = DataLayout.fromString(dataLayoutString);
|
|
|
|
getBackupConfig(async function (error, backupConfig) {
|
|
if (error) return callback(error);
|
|
|
|
const [preconditionError] = await safe(storage.api(backupConfig.provider).checkPreconditions(backupConfig, dataLayout));
|
|
if (preconditionError) return callback(preconditionError);
|
|
|
|
if (format === 'tgz') {
|
|
async.retry({ times: 5, interval: 20000 }, function (retryCallback) {
|
|
retryCallback = once(retryCallback); // protect again upload() erroring much later after tar stream error
|
|
|
|
tarPack(dataLayout, backupConfig.encryption, function (error, tarStream) {
|
|
if (error) return retryCallback(error);
|
|
|
|
tarStream.on('progress', function (progress) {
|
|
const transferred = Math.round(progress.transferred/1024/1024), speed = Math.round(progress.speed/1024/1024);
|
|
if (!transferred && !speed) return progressCallback({ message: 'Uploading backup' }); // 0M@0MBps looks wrong
|
|
progressCallback({ message: `Uploading backup ${transferred}M@${speed}MBps` });
|
|
});
|
|
tarStream.on('error', retryCallback); // already returns BoxError
|
|
|
|
storage.api(backupConfig.provider).upload(backupConfig, storage.getBackupFilePath(backupConfig, remotePath, format), tarStream, retryCallback);
|
|
});
|
|
}, callback);
|
|
} else {
|
|
async.series([
|
|
saveFsMetadata.bind(null, dataLayout, `${dataLayout.localRoot()}/fsmetadata.json`),
|
|
sync.bind(null, backupConfig, remotePath, dataLayout, progressCallback)
|
|
], callback);
|
|
}
|
|
});
|
|
}
|
|
|
|
function tarExtract(inStream, dataLayout, encryption, callback) {
|
|
assert.strictEqual(typeof inStream, 'object');
|
|
assert(dataLayout instanceof DataLayout, 'dataLayout must be a DataLayout');
|
|
assert.strictEqual(typeof encryption, 'object');
|
|
assert.strictEqual(typeof callback, 'function');
|
|
|
|
const gunzip = zlib.createGunzip({});
|
|
const ps = progressStream({ time: 10000 }); // display a progress every 10 seconds
|
|
const extract = tar.extract('/', {
|
|
map: function (header) {
|
|
header.name = dataLayout.toLocalPath(header.name);
|
|
return header;
|
|
},
|
|
dmode: 500 // ensure directory is writable
|
|
});
|
|
|
|
const emitError = once((error) => {
|
|
inStream.destroy();
|
|
ps.emit('error', error);
|
|
});
|
|
|
|
inStream.on('error', function (error) {
|
|
debug('tarExtract: input stream error.', error);
|
|
emitError(new BoxError(BoxError.EXTERNAL_ERROR, error.message));
|
|
});
|
|
|
|
gunzip.on('error', function (error) {
|
|
debug('tarExtract: gunzip stream error.', error);
|
|
emitError(new BoxError(BoxError.EXTERNAL_ERROR, error.message));
|
|
});
|
|
|
|
extract.on('error', function (error) {
|
|
debug('tarExtract: extract stream error.', error);
|
|
emitError(new BoxError(BoxError.EXTERNAL_ERROR, error.message));
|
|
});
|
|
|
|
extract.on('finish', function () {
|
|
debug('tarExtract: done.');
|
|
// we use a separate event because ps is a through2 stream which emits 'finish' event indicating end of inStream and not extract
|
|
ps.emit('done');
|
|
});
|
|
|
|
if (encryption) {
|
|
let decrypt = new DecryptStream(encryption);
|
|
decrypt.on('error', function (error) {
|
|
debug('tarExtract: decrypt stream error.', error);
|
|
emitError(new BoxError(BoxError.EXTERNAL_ERROR, `Failed to decrypt: ${error.message}`));
|
|
});
|
|
inStream.pipe(ps).pipe(decrypt).pipe(gunzip).pipe(extract);
|
|
} else {
|
|
inStream.pipe(ps).pipe(gunzip).pipe(extract);
|
|
}
|
|
|
|
callback(null, ps);
|
|
}
|
|
|
|
async function restoreFsMetadata(dataLayout, metadataFile) {
|
|
assert(dataLayout instanceof DataLayout, 'dataLayout must be a DataLayout');
|
|
assert.strictEqual(typeof metadataFile, 'string');
|
|
|
|
debug(`Recreating empty directories in ${dataLayout.toString()}`);
|
|
|
|
const metadataJson = safe.fs.readFileSync(metadataFile, 'utf8');
|
|
if (metadataJson === null) throw new BoxError(BoxError.EXTERNAL_ERROR, 'Error loading fsmetadata.json:' + safe.error.message);
|
|
const metadata = safe.JSON.parse(metadataJson);
|
|
if (metadata === null) throw new BoxError(BoxError.EXTERNAL_ERROR, 'Error parsing fsmetadata.json:' + safe.error.message);
|
|
|
|
for (const emptyDir of metadata.emptyDirs) {
|
|
const [mkdirError] = await safe(fs.promises.mkdir(dataLayout.toLocalPath(emptyDir), { recursive: true }));
|
|
if (mkdirError) throw new BoxError(BoxError.FS_ERROR, `unable to create path: ${mkdirError.message}`);
|
|
}
|
|
|
|
for (const execFile of metadata.execFiles) {
|
|
const [chmodError] = await safe(fs.promises.chmod(dataLayout.toLocalPath(execFile), parseInt('0755', 8)));
|
|
if (chmodError) throw new BoxError(BoxError.FS_ERROR, `unable to chmod: ${chmodError.message}`);
|
|
}
|
|
|
|
for (const symlink of (metadata.symlinks || [])) {
|
|
if (!symlink.target) continue;
|
|
// the path may not exist if we had a directory full of symlinks
|
|
const [mkdirError] = await safe(fs.promises.mkdir(path.dirname(dataLayout.toLocalPath(symlink.path)), { recursive: true }));
|
|
if (mkdirError) throw new BoxError(BoxError.FS_ERROR, `unable to symlink (mkdir): ${mkdirError.message}`);
|
|
const [symlinkError] = await safe(fs.promises.symlink(symlink.target, dataLayout.toLocalPath(symlink.path), 'file'));
|
|
if (symlinkError) throw new BoxError(BoxError.FS_ERROR, `unable to symlink: ${symlinkError.message}`);
|
|
}
|
|
}
|
|
|
|
function downloadDir(backupConfig, backupFilePath, dataLayout, progressCallback, callback) {
|
|
assert.strictEqual(typeof backupConfig, 'object');
|
|
assert.strictEqual(typeof backupFilePath, 'string');
|
|
assert(dataLayout instanceof DataLayout, 'dataLayout must be a DataLayout');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
assert.strictEqual(typeof callback, 'function');
|
|
|
|
debug(`downloadDir: ${backupFilePath} to ${dataLayout.toString()}`);
|
|
|
|
function downloadFile(entry, done) {
|
|
let relativePath = path.relative(backupFilePath, entry.fullPath);
|
|
if (backupConfig.encryption) {
|
|
const { error, result } = decryptFilePath(relativePath, backupConfig.encryption);
|
|
if (error) return done(new BoxError(BoxError.CRYPTO_ERROR, 'Unable to decrypt file'));
|
|
relativePath = result;
|
|
}
|
|
const destFilePath = dataLayout.toLocalPath('./' + relativePath);
|
|
|
|
fs.mkdir(path.dirname(destFilePath), { recursive: true }, function (error) {
|
|
if (error) return done(new BoxError(BoxError.FS_ERROR, error.message));
|
|
|
|
async.retry({ times: 5, interval: 20000 }, function (retryCallback) {
|
|
storage.api(backupConfig.provider).download(backupConfig, entry.fullPath, function (error, sourceStream) {
|
|
if (error) {
|
|
progressCallback({ message: `Download ${entry.fullPath} to ${destFilePath} errored: ${error.message}` });
|
|
return retryCallback(error);
|
|
}
|
|
|
|
let destStream = createWriteStream(destFilePath, backupConfig.encryption);
|
|
|
|
// protect against multiple errors. must destroy the write stream so that a previous retry does not write
|
|
let closeAndRetry = once((error) => {
|
|
if (error) progressCallback({ message: `Download ${entry.fullPath} to ${destFilePath} errored: ${error.message}` });
|
|
else progressCallback({ message: `Download ${entry.fullPath} to ${destFilePath} finished` });
|
|
sourceStream.destroy();
|
|
destStream.destroy();
|
|
retryCallback(error);
|
|
});
|
|
|
|
destStream.on('progress', function (progress) {
|
|
const transferred = Math.round(progress.transferred/1024/1024), speed = Math.round(progress.speed/1024/1024);
|
|
if (!transferred && !speed) return progressCallback({ message: `Downloading ${entry.fullPath}` }); // 0M@0MBps looks wrong
|
|
progressCallback({ message: `Downloading ${entry.fullPath}: ${transferred}M@${speed}MBps` });
|
|
});
|
|
destStream.on('error', closeAndRetry);
|
|
|
|
sourceStream.on('error', closeAndRetry);
|
|
|
|
progressCallback({ message: `Downloading ${entry.fullPath} to ${destFilePath}` });
|
|
|
|
sourceStream.pipe(destStream, { end: true }).on('done', closeAndRetry);
|
|
});
|
|
}, done);
|
|
});
|
|
}
|
|
|
|
storage.api(backupConfig.provider).listDir(backupConfig, backupFilePath, 1000, function (entries, iteratorDone) {
|
|
// https://www.digitalocean.com/community/questions/rate-limiting-on-spaces?answer=40441
|
|
const concurrency = backupConfig.downloadConcurrency || (backupConfig.provider === 's3' ? 30 : 10);
|
|
|
|
async.eachLimit(entries, concurrency, downloadFile, iteratorDone);
|
|
}, callback);
|
|
}
|
|
|
|
function download(backupConfig, remotePath, format, dataLayout, progressCallback, callback) {
|
|
assert.strictEqual(typeof backupConfig, 'object');
|
|
assert.strictEqual(typeof remotePath, 'string');
|
|
assert.strictEqual(typeof format, 'string');
|
|
assert(dataLayout instanceof DataLayout, 'dataLayout must be a DataLayout');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
assert.strictEqual(typeof callback, 'function');
|
|
|
|
debug(`download: Downloading ${remotePath} of format ${format} to ${dataLayout.toString()}`);
|
|
|
|
const backupFilePath = storage.getBackupFilePath(backupConfig, remotePath, format);
|
|
|
|
if (format === 'tgz') {
|
|
async.retry({ times: 5, interval: 20000 }, function (retryCallback) {
|
|
progressCallback({ message: `Downloading backup ${remotePath}` });
|
|
|
|
storage.api(backupConfig.provider).download(backupConfig, backupFilePath, function (error, sourceStream) {
|
|
if (error) return retryCallback(error);
|
|
|
|
tarExtract(sourceStream, dataLayout, backupConfig.encryption, function (error, ps) {
|
|
if (error) return retryCallback(error);
|
|
|
|
ps.on('progress', function (progress) {
|
|
const transferred = Math.round(progress.transferred/1024/1024), speed = Math.round(progress.speed/1024/1024);
|
|
if (!transferred && !speed) return progressCallback({ message: 'Downloading backup' }); // 0M@0MBps looks wrong
|
|
progressCallback({ message: `Downloading ${transferred}M@${speed}MBps` });
|
|
});
|
|
ps.on('error', retryCallback);
|
|
ps.on('done', retryCallback);
|
|
});
|
|
});
|
|
}, callback);
|
|
} else {
|
|
downloadDir(backupConfig, backupFilePath, dataLayout, progressCallback, async function (error) {
|
|
if (error) return callback(error);
|
|
|
|
[error] = await safe(restoreFsMetadata(dataLayout, `${dataLayout.localRoot()}/fsmetadata.json`));
|
|
callback(error);
|
|
});
|
|
}
|
|
}
|
|
|
|
async function restore(backupConfig, remotePath, progressCallback) {
|
|
assert.strictEqual(typeof backupConfig, 'object');
|
|
assert.strictEqual(typeof remotePath, 'string');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
const boxDataDir = safe.fs.realpathSync(paths.BOX_DATA_DIR);
|
|
if (!boxDataDir) throw new BoxError(BoxError.FS_ERROR, `Error resolving boxdata: ${safe.error.message}`);
|
|
const dataLayout = new DataLayout(boxDataDir, []);
|
|
|
|
await util.promisify(download)(backupConfig, remotePath, backupConfig.format, dataLayout, progressCallback);
|
|
|
|
debug('restore: download completed, importing database');
|
|
|
|
await database.importFromFile(`${dataLayout.localRoot()}/box.mysqldump`);
|
|
|
|
debug('restore: database imported');
|
|
|
|
await settings.initCache();
|
|
}
|
|
|
|
async function downloadApp(app, restoreConfig, progressCallback) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
assert.strictEqual(typeof restoreConfig, 'object');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
const appDataDir = safe.fs.realpathSync(path.join(paths.APPS_DATA_DIR, app.id));
|
|
if (!appDataDir) throw new BoxError(BoxError.FS_ERROR, safe.error.message);
|
|
const dataLayout = new DataLayout(appDataDir, app.dataDir ? [{ localDir: app.dataDir, remoteDir: 'data' }] : []);
|
|
|
|
const startTime = new Date();
|
|
const backupConfig = restoreConfig.backupConfig || await settings.getBackupConfig();
|
|
|
|
const downloadAsync = util.promisify(download);
|
|
await downloadAsync(backupConfig, restoreConfig.remotePath, restoreConfig.backupFormat, dataLayout, progressCallback);
|
|
debug('downloadApp: time: %s', (new Date() - startTime)/1000);
|
|
}
|
|
|
|
function runBackupUpload(uploadConfig, progressCallback, callback) {
|
|
assert.strictEqual(typeof uploadConfig, 'object');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
assert.strictEqual(typeof callback, 'function');
|
|
|
|
const { remotePath, backupConfig, dataLayout, progressTag } = uploadConfig;
|
|
assert.strictEqual(typeof remotePath, 'string');
|
|
assert.strictEqual(typeof backupConfig, 'object');
|
|
assert.strictEqual(typeof progressTag, 'string');
|
|
assert(dataLayout instanceof DataLayout, 'dataLayout must be a DataLayout');
|
|
|
|
let result = ''; // the script communicates error result as a string
|
|
|
|
// https://stackoverflow.com/questions/48387040/node-js-recommended-max-old-space-size
|
|
const envCopy = Object.assign({}, process.env);
|
|
if (backupConfig.memoryLimit && backupConfig.memoryLimit >= 2*1024*1024*1024) {
|
|
const heapSize = Math.min((backupConfig.memoryLimit/1024/1024) - 256, 8192);
|
|
debug(`runBackupUpload: adjusting heap size to ${heapSize}M`);
|
|
envCopy.NODE_OPTIONS = `--max-old-space-size=${heapSize}`;
|
|
}
|
|
|
|
shell.sudo(`backup-${remotePath}`, [ BACKUP_UPLOAD_CMD, remotePath, backupConfig.format, dataLayout.toString() ], { env: envCopy, preserveEnv: true, ipc: true }, function (error) {
|
|
if (error && (error.code === null /* signal */ || (error.code !== 0 && error.code !== 50))) { // backuptask crashed
|
|
return callback(new BoxError(BoxError.INTERNAL_ERROR, 'Backuptask crashed'));
|
|
} else if (error && error.code === 50) { // exited with error
|
|
return callback(new BoxError(BoxError.EXTERNAL_ERROR, result));
|
|
}
|
|
|
|
callback();
|
|
}).on('message', function (progress) { // this is { message } or { result }
|
|
if ('message' in progress) return progressCallback({ message: `${progress.message} (${progressTag})` });
|
|
debug(`runBackupUpload: result - ${JSON.stringify(progress)}`);
|
|
result = progress.result;
|
|
});
|
|
}
|
|
|
|
async function snapshotBox(progressCallback) {
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
progressCallback({ message: 'Snapshotting box' });
|
|
|
|
const startTime = new Date();
|
|
|
|
await database.exportToFile(`${paths.BOX_DATA_DIR}/box.mysqldump`);
|
|
debug(`snapshotBox: took ${(new Date() - startTime)/1000} seconds`);
|
|
}
|
|
|
|
async function uploadBoxSnapshot(backupConfig, progressCallback) {
|
|
assert.strictEqual(typeof backupConfig, 'object');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
await snapshotBox(progressCallback);
|
|
|
|
const boxDataDir = safe.fs.realpathSync(paths.BOX_DATA_DIR);
|
|
if (!boxDataDir) throw new BoxError(BoxError.FS_ERROR, `Error resolving boxdata: ${safe.error.message}`);
|
|
|
|
const uploadConfig = {
|
|
remotePath: 'snapshot/box',
|
|
backupConfig,
|
|
dataLayout: new DataLayout(boxDataDir, []),
|
|
progressTag: 'box'
|
|
};
|
|
|
|
progressCallback({ message: 'Uploading box snapshot' });
|
|
|
|
const startTime = new Date();
|
|
|
|
await runBackupUploadAsync(uploadConfig, progressCallback);
|
|
|
|
debug(`uploadBoxSnapshot: took ${(new Date() - startTime)/1000} seconds`);
|
|
|
|
await backups.setSnapshotInfo('box', { timestamp: new Date().toISOString(), format: backupConfig.format });
|
|
}
|
|
|
|
async function copy(backupConfig, srcRemotePath, destRemotePath, progressCallback) {
|
|
assert.strictEqual(typeof backupConfig, 'object');
|
|
assert.strictEqual(typeof srcRemotePath, 'string');
|
|
assert.strictEqual(typeof destRemotePath, 'string');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
const { provider, format } = backupConfig;
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const startTime = new Date();
|
|
|
|
const copyEvents = storage.api(provider).copy(backupConfig, storage.getBackupFilePath(backupConfig, srcRemotePath, format), storage.getBackupFilePath(backupConfig, destRemotePath, format));
|
|
copyEvents.on('progress', (message) => progressCallback({ message }));
|
|
copyEvents.on('done', function (error) {
|
|
if (error) return reject(error);
|
|
debug(`copy: copied successfully to ${destRemotePath}. Took ${(new Date() - startTime)/1000} seconds`);
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
async function rotateBoxBackup(backupConfig, tag, options, dependsOn, progressCallback) {
|
|
assert.strictEqual(typeof backupConfig, 'object');
|
|
assert.strictEqual(typeof tag, 'string');
|
|
assert.strictEqual(typeof options, 'object');
|
|
assert(Array.isArray(dependsOn));
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
const remotePath = `${tag}/box_v${constants.VERSION}`;
|
|
const format = backupConfig.format;
|
|
|
|
debug(`rotateBoxBackup: rotating to id ${remotePath}`);
|
|
|
|
const data = {
|
|
remotePath,
|
|
encryptionVersion: backupConfig.encryption ? 2 : null,
|
|
packageVersion: constants.VERSION,
|
|
type: backups.BACKUP_TYPE_BOX,
|
|
state: backups.BACKUP_STATE_CREATING,
|
|
identifier: backups.BACKUP_IDENTIFIER_BOX,
|
|
dependsOn,
|
|
manifest: null,
|
|
format,
|
|
preserveSecs: options.preserveSecs || 0
|
|
};
|
|
|
|
const id = await backups.add(data);
|
|
const [error] = await safe(copy(backupConfig, 'snapshot/box', remotePath, progressCallback));
|
|
const state = error ? backups.BACKUP_STATE_ERROR : backups.BACKUP_STATE_NORMAL;
|
|
await backups.setState(id, state);
|
|
if (error) throw error;
|
|
|
|
return id;
|
|
}
|
|
|
|
async function backupBox(dependsOn, tag, options, progressCallback) {
|
|
assert(Array.isArray(dependsOn));
|
|
assert.strictEqual(typeof tag, 'string');
|
|
assert.strictEqual(typeof options, 'object');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
const backupConfig = await settings.getBackupConfig();
|
|
|
|
await uploadBoxSnapshot(backupConfig, progressCallback);
|
|
return await rotateBoxBackup(backupConfig, tag, options, dependsOn, progressCallback);
|
|
}
|
|
|
|
async function rotateAppBackup(backupConfig, app, tag, options, progressCallback) {
|
|
assert.strictEqual(typeof backupConfig, 'object');
|
|
assert.strictEqual(typeof app, 'object');
|
|
assert.strictEqual(typeof tag, 'string');
|
|
assert.strictEqual(typeof options, 'object');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
const snapshotInfo = backups.getSnapshotInfo(app.id);
|
|
|
|
const manifest = snapshotInfo.restoreConfig ? snapshotInfo.restoreConfig.manifest : snapshotInfo.manifest; // compat
|
|
const remotePath = `${tag}/app_${app.fqdn}_v${manifest.version}`;
|
|
const format = backupConfig.format;
|
|
|
|
debug(`rotateAppBackup: rotating ${app.fqdn} to path ${remotePath}`);
|
|
|
|
const data = {
|
|
remotePath,
|
|
encryptionVersion: backupConfig.encryption ? 2 : null,
|
|
packageVersion: manifest.version,
|
|
type: backups.BACKUP_TYPE_APP,
|
|
state: backups.BACKUP_STATE_CREATING,
|
|
identifier: app.id,
|
|
dependsOn: [],
|
|
manifest,
|
|
format,
|
|
preserveSecs: options.preserveSecs || 0
|
|
};
|
|
|
|
const id = await backups.add(data);
|
|
const [error] = await safe(copy(backupConfig, `snapshot/app_${app.id}`, remotePath, progressCallback));
|
|
const state = error ? backups.BACKUP_STATE_ERROR : backups.BACKUP_STATE_NORMAL;
|
|
await backups.setState(id, state);
|
|
if (error) throw error;
|
|
|
|
return id;
|
|
}
|
|
|
|
async function backupApp(app, options, progressCallback) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
assert.strictEqual(typeof options, 'object');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
if (options.snapshotOnly) return await snapshotApp(app, progressCallback);
|
|
|
|
const tag = (new Date()).toISOString().replace(/[T.]/g, '-').replace(/[:Z]/g,'');
|
|
|
|
debug(`backupApp: backing up ${app.fqdn} with tag ${tag}`);
|
|
|
|
return await backupAppWithTag(app, tag, options, progressCallback);
|
|
}
|
|
|
|
async function snapshotApp(app, progressCallback) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
const startTime = new Date();
|
|
progressCallback({ message: `Snapshotting app ${app.fqdn}` });
|
|
|
|
await apps.backupConfig(app);
|
|
await services.backupAddons(app, app.manifest.addons);
|
|
|
|
debug(`snapshotApp: ${app.fqdn} took ${(new Date() - startTime)/1000} seconds`);
|
|
}
|
|
|
|
async function uploadAppSnapshot(backupConfig, app, progressCallback) {
|
|
assert.strictEqual(typeof backupConfig, 'object');
|
|
assert.strictEqual(typeof app, 'object');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
await snapshotApp(app, progressCallback);
|
|
|
|
const remotePath = util.format('snapshot/app_%s', app.id);
|
|
const appDataDir = safe.fs.realpathSync(path.join(paths.APPS_DATA_DIR, app.id));
|
|
if (!appDataDir) throw new BoxError(BoxError.FS_ERROR, `Error resolving appsdata: ${safe.error.message}`);
|
|
|
|
const dataLayout = new DataLayout(appDataDir, app.dataDir ? [{ localDir: app.dataDir, remoteDir: 'data' }] : []);
|
|
|
|
progressCallback({ message: `Uploading app snapshot ${app.fqdn}`});
|
|
|
|
const uploadConfig = {
|
|
remotePath,
|
|
backupConfig,
|
|
dataLayout,
|
|
progressTag: app.fqdn
|
|
};
|
|
|
|
const startTime = new Date();
|
|
|
|
await runBackupUploadAsync(uploadConfig, progressCallback);
|
|
|
|
debug(`uploadAppSnapshot: ${app.fqdn} upload to ${remotePath}. ${(new Date() - startTime)/1000} seconds`);
|
|
|
|
await backups.setSnapshotInfo(app.id, { timestamp: new Date().toISOString(), manifest: app.manifest, format: backupConfig.format });
|
|
}
|
|
|
|
async function backupAppWithTag(app, tag, options, progressCallback) {
|
|
assert.strictEqual(typeof app, 'object');
|
|
assert.strictEqual(typeof tag, 'string');
|
|
assert.strictEqual(typeof options, 'object');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
if (!canBackupApp(app)) { // if we cannot backup, reuse it's most recent backup
|
|
const results = await backups.getByIdentifierAndStatePaged(app.id, backups.BACKUP_STATE_NORMAL, 1, 1);
|
|
if (results.length === 0) return null; // no backup to re-use
|
|
|
|
return results[0].id;
|
|
}
|
|
|
|
const backupConfig = await settings.getBackupConfig();
|
|
|
|
await uploadAppSnapshot(backupConfig, app, progressCallback);
|
|
return await rotateAppBackup(backupConfig, app, tag, options, progressCallback);
|
|
}
|
|
|
|
async function uploadMailSnapshot(backupConfig, progressCallback) {
|
|
assert.strictEqual(typeof backupConfig, 'object');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
const mailDataDir = safe.fs.realpathSync(paths.MAIL_DATA_DIR);
|
|
if (!mailDataDir) throw new BoxError(BoxError.FS_ERROR, `Error resolving maildata: ${safe.error.message}`);
|
|
|
|
const uploadConfig = {
|
|
remotePath: 'snapshot/mail',
|
|
backupConfig,
|
|
dataLayout: new DataLayout(mailDataDir, []),
|
|
progressTag: 'mail'
|
|
};
|
|
|
|
progressCallback({ message: 'Uploading mail snapshot' });
|
|
|
|
const startTime = new Date();
|
|
|
|
await runBackupUploadAsync(uploadConfig, progressCallback);
|
|
|
|
debug(`uploadMailSnapshot: took ${(new Date() - startTime)/1000} seconds`);
|
|
|
|
await backups.setSnapshotInfo('mail', { timestamp: new Date().toISOString(), format: backupConfig.format });
|
|
}
|
|
|
|
async function rotateMailBackup(backupConfig, tag, options, progressCallback) {
|
|
assert.strictEqual(typeof backupConfig, 'object');
|
|
assert.strictEqual(typeof tag, 'string');
|
|
assert.strictEqual(typeof options, 'object');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
const remotePath = `${tag}/mail_v${constants.VERSION}`;
|
|
const format = backupConfig.format;
|
|
|
|
debug(`rotateMailBackup: rotating to ${remotePath}`);
|
|
|
|
const data = {
|
|
remotePath,
|
|
encryptionVersion: backupConfig.encryption ? 2 : null,
|
|
packageVersion: constants.VERSION,
|
|
type: backups.BACKUP_TYPE_MAIL,
|
|
state: backups.BACKUP_STATE_CREATING,
|
|
identifier: backups.BACKUP_IDENTIFIER_MAIL,
|
|
dependsOn: [],
|
|
manifest: null,
|
|
format,
|
|
preserveSecs: options.preserveSecs || 0
|
|
};
|
|
|
|
const id = await backups.add(data);
|
|
const [error] = await safe(copy(backupConfig, 'snapshot/mail', remotePath, progressCallback));
|
|
const state = error ? backups.BACKUP_STATE_ERROR : backups.BACKUP_STATE_NORMAL;
|
|
await backups.setState(id, state);
|
|
if (error) throw error;
|
|
|
|
return id;
|
|
}
|
|
|
|
async function backupMailWithTag(tag, options, progressCallback) {
|
|
assert.strictEqual(typeof tag, 'string');
|
|
assert.strictEqual(typeof options, 'object');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
debug(`backupMailWithTag: backing up mail with tag ${tag}`);
|
|
|
|
const backupConfig = await settings.getBackupConfig();
|
|
await uploadMailSnapshot(backupConfig, progressCallback);
|
|
return await rotateMailBackup(backupConfig, tag, options, progressCallback);
|
|
}
|
|
|
|
async function backupMail(options, progressCallback) {
|
|
assert.strictEqual(typeof options, 'object');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
const tag = (new Date()).toISOString().replace(/[T.]/g, '-').replace(/[:Z]/g,'');
|
|
debug(`backupMail: backing up mail with tag ${tag}`);
|
|
return await backupMailWithTag(tag, options, progressCallback);
|
|
}
|
|
|
|
async function downloadMail(restoreConfig, progressCallback) {
|
|
assert.strictEqual(typeof restoreConfig, 'object');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
const mailDataDir = safe.fs.realpathSync(paths.MAIL_DATA_DIR);
|
|
if (!mailDataDir) throw new BoxError(BoxError.FS_ERROR, `Error resolving maildata: ${safe.error.message}`);
|
|
const dataLayout = new DataLayout(mailDataDir, []);
|
|
|
|
const startTime = new Date();
|
|
|
|
const downloadAsync = util.promisify(download);
|
|
await downloadAsync(restoreConfig.backupConfig, restoreConfig.remotePath, restoreConfig.backupFormat, dataLayout, progressCallback);
|
|
debug('downloadMail: time: %s', (new Date() - startTime)/1000);
|
|
}
|
|
|
|
// this function is called from external process. calling process is expected to have a lock
|
|
async function fullBackup(options, progressCallback) {
|
|
assert.strictEqual(typeof options, 'object');
|
|
assert.strictEqual(typeof progressCallback, 'function');
|
|
|
|
const tag = (new Date()).toISOString().replace(/[T.]/g, '-').replace(/[:Z]/g,''); // unique tag under which all apps/mail/box backs up
|
|
|
|
const allApps = await apps.list();
|
|
|
|
let percent = 1;
|
|
let step = 100/(allApps.length+3);
|
|
|
|
const appBackupIds = [];
|
|
for (let i = 0; i < allApps.length; i++) {
|
|
const app = allApps[i];
|
|
progressCallback({ percent: percent, message: `Backing up ${app.fqdn} (${i+1}/${allApps.length})` });
|
|
percent += step;
|
|
|
|
if (!app.enableBackup) {
|
|
debug(`fullBackup: skipped backup ${app.fqdn} (${i+1}/${allApps.length}) since automatic backup disabled`);
|
|
continue; // nothing to backup
|
|
}
|
|
|
|
const startTime = new Date();
|
|
const appBackupId = await backupAppWithTag(app, tag, options, (progress) => progressCallback({ percent, message: progress.message }));
|
|
debug(`fullBackup: app ${app.fqdn} backup finished. Took ${(new Date() - startTime)/1000} seconds`);
|
|
if (appBackupId) appBackupIds.push(appBackupId); // backupId can be null if in BAD_STATE and never backed up
|
|
}
|
|
|
|
progressCallback({ percent, message: 'Backing up mail' });
|
|
percent += step;
|
|
const mailBackupId = await backupMailWithTag(tag, options, (progress) => progressCallback({ percent, message: progress.message }));
|
|
|
|
progressCallback({ percent, message: 'Backing up system data' });
|
|
percent += step;
|
|
|
|
const dependsOn = appBackupIds.concat(mailBackupId);
|
|
const backupId = await backupBox(dependsOn, tag, options, (progress) => progressCallback({ percent, message: progress.message }));
|
|
return backupId;
|
|
}
|