diff --git a/src/backups.js b/src/backups.js index 053ba83af..079d384c6 100644 --- a/src/backups.js +++ b/src/backups.js @@ -97,6 +97,7 @@ function api(provider) { case 'cifs': return require('./storage/filesystem.js'); case 'sshfs': return require('./storage/filesystem.js'); case 'mountpoint': return require('./storage/filesystem.js'); + case 'ext4': return require('./storage/filesystem.js'); case 's3': return require('./storage/s3.js'); case 'gcs': return require('./storage/gcs.js'); case 'filesystem': return require('./storage/filesystem.js'); diff --git a/src/mounts.js b/src/mounts.js index 96ddb5f4e..7395e7cff 100644 --- a/src/mounts.js +++ b/src/mounts.js @@ -26,7 +26,8 @@ function validateMountOptions(type, options) { assert.strictEqual(typeof options, 'object'); switch (type) { - case 'noop': + case 'noop': // volume provider + case 'mountpoint': // backup provider return null; case 'cifs': if (typeof options.username !== 'string') return new BoxError(BoxError.BAD_FIELD, 'username is not a string'); @@ -92,7 +93,7 @@ async function getStatus(mountType, hostPath) { assert.strictEqual(typeof mountType, 'string'); assert.strictEqual(typeof hostPath, 'string'); - if (mountType === 'noop') { + if (mountType === 'noop' || mountType === 'mountpoint') { // noop is from volume provider and mountpoint is from backup provider if (safe.child_process.execSync(`mountpoint -q -- ${hostPath}`, { encoding: 'utf8' })) { return { state: 'active', message: 'Mounted' }; } else { @@ -112,7 +113,7 @@ async function getStatus(mountType, hostPath) { const idx = rlines.findIndex(l => l.startsWith('mount ') || l.startsWith('mount.')); if (idx !== -1) message = rlines[idx]; } - if (!message) message = `Could not determine failure reason: ${safe.error.message}`; + if (!message) message = `Could not determine failure reason. ${safe.error ? safe.error.message : ''}`; } else { message = 'Mounted'; } diff --git a/src/routes/settings.js b/src/routes/settings.js index 8b43d7c2f..833ac8939 100644 --- a/src/routes/settings.js +++ b/src/routes/settings.js @@ -106,7 +106,6 @@ function setBackupConfig(req, res, next) { if (!req.body.retentionPolicy || typeof req.body.retentionPolicy !== 'object') return next(new HttpError(400, 'retentionPolicy is required')); - if ('mountType' in req.body && typeof req.body.mountType !== 'string') return next(new HttpError(400, 'mountType must be a string')); if ('mountOptions' in req.body && typeof req.body.mountOptions !== 'object') return next(new HttpError(400, 'mountOptions must be a object')); // testing the backup using put/del takes a bit of time at times diff --git a/src/settings.js b/src/settings.js index ab388e9af..413dc7f48 100644 --- a/src/settings.js +++ b/src/settings.js @@ -132,7 +132,6 @@ const assert = require('assert'), externalLdap = require('./externalldap.js'), moment = require('moment-timezone'), mounts = require('./mounts.js'), - path = require('path'), paths = require('./paths.js'), safe = require('safetydance'), settingsdb = require('./settingsdb.js'), @@ -386,8 +385,8 @@ function getBackupConfig(callback) { const backupConfig = JSON.parse(value); // provider, token, password, region, prefix, bucket - if (backupConfig.provider === 'sshfs' || backupConfig.provider === 'cifs' || backupConfig.provider === 'nfs') { - backupConfig.mountStatus = await mounts.getStatus(backupConfig.mountType || 'noop', backupConfig.mountPoint); // { state, message } + if (backupConfig.provider === 'sshfs' || backupConfig.provider === 'cifs' || backupConfig.provider === 'nfs' || backupConfig.provider === 'ext4' || backupConfig.provider === 'mountpoint') { + backupConfig.mountStatus = await mounts.getStatus(backupConfig.provider, backupConfig.mountPoint); // { state, message } } callback(null, backupConfig); @@ -417,19 +416,22 @@ function setBackupConfig(backupConfig, callback) { backups.cleanupCacheFilesSync(); } - if ('mountType' in backupConfig) { - error = mounts.validateMountOptions(backupConfig.mountType, backupConfig.mountOptions); + if (backupConfig.provider === 'sshfs' || backupConfig.provider === 'cifs' || backupConfig.provider === 'nfs' || backupConfig.provider === 'ext4') { + error = mounts.validateMountOptions(backupConfig.provider, backupConfig.mountOptions); if (error) return callback(error); const backupVolume = { name: 'backup', hostPath: backupConfig.mountPoint, - mountType: backupConfig.mountType, + mountType: backupConfig.provider, mountOptions: backupConfig.mountOptions }; - [error] = await safe(backupConfig.mountType === 'noop' ? mounts.removeMountFile(backupVolume.hostPath) : mounts.writeMountFile(backupVolume)); + [error] = await safe(mounts.writeMountFile(backupVolume)); if (error) return callback(error); + } else if (currentConfig.provider === 'sshfs' || currentConfig.provider === 'cifs' || currentConfig.provider === 'nfs' || currentConfig.provider === 'ext4') { + debug('setBackupConfig: removing old mount configuration'); + await safe(mounts.removeMountFile(currentConfig.hostPath)); } settingsdb.set(exports.BACKUP_CONFIG_KEY, JSON.stringify(backupConfig), function (error) { diff --git a/src/storage/filesystem.js b/src/storage/filesystem.js index 0c4372666..c634f8ab7 100644 --- a/src/storage/filesystem.js +++ b/src/storage/filesystem.js @@ -25,9 +25,11 @@ const PROVIDER_MOUNTPOINT = 'mountpoint'; const PROVIDER_SSHFS = 'sshfs'; const PROVIDER_CIFS = 'cifs'; const PROVIDER_NFS = 'nfs'; +const PROVIDER_EXT4 = 'ext4'; -var assert = require('assert'), +const assert = require('assert'), BoxError = require('../boxerror.js'), + constants = require('../constants.js'), DataLayout = require('../datalayout.js'), debug = require('debug')('box:storage/filesystem'), df = require('@sindresorhus/df'), @@ -49,6 +51,7 @@ function getBackupPath(apiConfig) { case PROVIDER_MOUNTPOINT: case PROVIDER_NFS: case PROVIDER_CIFS: + case PROVIDER_EXT4: return path.join(apiConfig.mountPoint, apiConfig.prefix); default: return apiConfig.backupFolder; @@ -251,10 +254,10 @@ function removeDir(apiConfig, pathPrefix) { function validateBackupTarget(folder) { assert.strictEqual(typeof folder, 'string'); - if (path.normalize(folder) !== folder) return new BoxError(BoxError.BAD_FIELD, 'backupFolder must contain a normalized path', { field: 'backupFolder' }); - if (!path.isAbsolute(folder)) return new BoxError(BoxError.BAD_FIELD, 'backupFolder must be an absolute path', { field: 'backupFolder' }); + if (path.normalize(folder) !== folder) return new BoxError(BoxError.BAD_FIELD, 'backupFolder/mountpoint must contain a normalized path', { field: 'backupFolder' }); + if (!path.isAbsolute(folder)) return new BoxError(BoxError.BAD_FIELD, 'backupFolder/mountpoint must be an absolute path', { field: 'backupFolder' }); - if (folder === '/') return new BoxError(BoxError.BAD_FIELD, 'backupFolder cannot be /', { field: 'backupFolder' }); + if (folder === '/') return new BoxError(BoxError.BAD_FIELD, 'backupFolder/mountpoint cannot be /', { field: 'backupFolder' }); if (!folder.endsWith('/')) folder = folder + '/'; // ensure trailing slash for the prefix matching to work const PROTECTED_PREFIXES = [ '/boot/', '/usr/', '/bin/', '/lib/', '/root/', '/var/lib/', paths.baseDir() ]; @@ -268,13 +271,13 @@ function testConfig(apiConfig, callback) { assert.strictEqual(typeof apiConfig, 'object'); assert.strictEqual(typeof callback, 'function'); + if ('noHardlinks' in apiConfig && typeof apiConfig.noHardlinks !== 'boolean') return callback(new BoxError(BoxError.BAD_FIELD, 'noHardlinks must be boolean', { field: 'noHardLinks' })); + if (apiConfig.provider === PROVIDER_FILESYSTEM) { if (!apiConfig.backupFolder || typeof apiConfig.backupFolder !== 'string') return callback(new BoxError(BoxError.BAD_FIELD, 'backupFolder must be non-empty string', { field: 'backupFolder' })); let error = validateBackupTarget(apiConfig.backupFolder); if (error) return callback(error); - } - - if (apiConfig.provider === PROVIDER_MOUNTPOINT) { + } else { // cifs/ext4/nfs/mountpoint/sshfs if (!apiConfig.mountPoint || typeof apiConfig.mountPoint !== 'string') return callback(new BoxError(BoxError.BAD_FIELD, 'mountPoint must be non-empty string', { field: 'mountPoint' })); let error = validateBackupTarget(apiConfig.mountPoint); if (error) return callback(error); @@ -290,35 +293,36 @@ function testConfig(apiConfig, callback) { if (!mountInfo) return callback(new BoxError(BoxError.BAD_FIELD, `${apiConfig.mountPoint} is not mounted`, { field: 'mountPoint' })); } - // common checks - const backupPath = getBackupPath(apiConfig); - const field = apiConfig.provider === PROVIDER_FILESYSTEM ? 'backupFolder' : 'prefix'; + if (apiConfig.provider === PROVIDER_FILESYSTEM || apiConfig.provider === PROVIDER_MOUNTPOINT) { + const backupPath = getBackupPath(apiConfig); + const field = apiConfig.provider === PROVIDER_FILESYSTEM ? 'backupFolder' : 'mountPoint'; - const stat = safe.fs.statSync(backupPath); - if (!stat) return callback(new BoxError(BoxError.BAD_FIELD, 'Directory does not exist or cannot be accessed: ' + safe.error.message), { field }); - if (!stat.isDirectory()) return callback(new BoxError(BoxError.BAD_FIELD, 'Backup location is not a directory', { field })); + const stat = safe.fs.statSync(backupPath); + if (!stat) return callback(new BoxError(BoxError.BAD_FIELD, 'Directory does not exist or cannot be accessed: ' + safe.error.message), { field }); + if (!stat.isDirectory()) return callback(new BoxError(BoxError.BAD_FIELD, 'Backup location is not a directory', { field })); - if (!safe.fs.mkdirSync(path.join(backupPath, 'snapshot')) && safe.error.code !== 'EEXIST') { - if (safe.error && safe.error.code === 'EACCES') return callback(new BoxError(BoxError.BAD_FIELD, `Access denied. Run "chown yellowtent:yellowtent ${backupPath}" on the server`, { field })); - return callback(new BoxError(BoxError.BAD_FIELD, safe.error.message, { field })); + if (!safe.fs.mkdirSync(path.join(backupPath, 'snapshot')) && safe.error.code !== 'EEXIST') { + if (safe.error && safe.error.code === 'EACCES') return callback(new BoxError(BoxError.BAD_FIELD, `Access denied. Run "chown yellowtent:yellowtent ${backupPath}" on the server`, { field })); + return callback(new BoxError(BoxError.BAD_FIELD, safe.error.message, { field })); + } + + if (!safe.fs.writeFileSync(path.join(backupPath, 'cloudron-testfile'), 'testcontent')) { + return callback(new BoxError(BoxError.BAD_FIELD, `Unable to create test file as 'yellowtent' user in ${backupPath}: ${safe.error.message}. Check dir/mount permissions`, { field })); + } + + if (!safe.fs.unlinkSync(path.join(backupPath, 'cloudron-testfile'))) { + return callback(new BoxError(BoxError.BAD_FIELD, `Unable to remove test file as 'yellowtent' user in ${backupPath}: ${safe.error.message}. Check dir/mount permissions`, { field })); + } } - if (!safe.fs.writeFileSync(path.join(backupPath, 'cloudron-testfile'), 'testcontent')) { - return callback(new BoxError(BoxError.BAD_FIELD, `Unable to create test file as 'yellowtent' user in ${backupPath}: ${safe.error.message}. Check dir/mount permissions`, { field })); - } - - if (!safe.fs.unlinkSync(path.join(backupPath, 'cloudron-testfile'))) { - return callback(new BoxError(BoxError.BAD_FIELD, `Unable to remove test file as 'yellowtent' user in ${backupPath}: ${safe.error.message}. Check dir/mount permissions`, { field })); - } - - if ('noHardlinks' in apiConfig && typeof apiConfig.noHardlinks !== 'boolean') return callback(new BoxError(BoxError.BAD_FIELD, 'noHardlinks must be boolean', { field: 'noHardLinks' })); - callback(null); } function removePrivateFields(apiConfig) { + if (apiConfig.mountOptions && apiConfig.mountOptions.password) apiConfig.mountOptions.password = constants.SECRET_PLACEHOLDER; return apiConfig; } -function injectPrivateFields(/* newConfig, currentConfig */) { +function injectPrivateFields(newConfig, currentConfig) { + if (newConfig.mountOptions && currentConfig.mountOptions && newConfig.mountOptions.password === constants.SECRET_PLACEHOLDER) newConfig.mountOptions.password = currentConfig.mountOptions.password; }