diff --git a/src/backups.js b/src/backups.js index 198da3655..2e3d470c5 100644 --- a/src/backups.js +++ b/src/backups.js @@ -31,6 +31,7 @@ exports = module.exports = { configureCollectd, generateEncryptionKeysSync, + isMountProvider, BACKUP_IDENTIFIER_BOX: 'box', @@ -116,6 +117,10 @@ function api(provider) { } } +function isMountProvider(provider) { + return provider === 'sshfs' || provider === 'cifs' || provider === 'nfs' || provider === 'ext4'; +} + function injectPrivateFields(newConfig, currentConfig) { if ('password' in newConfig) { if (newConfig.password === constants.SECRET_PLACEHOLDER) { diff --git a/src/provision.js b/src/provision.js index 4bd0813bd..a32e629b0 100644 --- a/src/provision.js +++ b/src/provision.js @@ -18,7 +18,9 @@ const assert = require('assert'), domains = require('./domains.js'), eventlog = require('./eventlog.js'), mail = require('./mail.js'), + mounts = require('./mounts.js'), reverseProxy = require('./reverseproxy.js'), + safe = require('safetydance'), semver = require('semver'), settings = require('./settings.js'), sysinfo = require('./sysinfo.js'), @@ -177,10 +179,25 @@ function restore(backupConfig, backupId, version, sysinfoConfig, options, auditS callback(error); } - users.isActivated(function (error, activated) { + users.isActivated(async function (error, activated) { if (error) return done(error); if (activated) return done(new BoxError(BoxError.CONFLICT, 'Already activated. Restore with a fresh Cloudron installation.')); + if (backups.isMountProvider(backupConfig.provider)) { + error = mounts.validateMountOptions(backupConfig.provider, backupConfig.mountOptions); + if (error) return callback(error); + + const newMount = { + name: 'backup', + hostPath: backupConfig.mountPoint, + mountType: backupConfig.provider, + mountOptions: backupConfig.mountOptions + }; + + [error] = await safe(mounts.tryAddMount(newMount, null, { times: 20, interval: 500 })); // 10 seconds + if (error) return callback(error); + } + backups.testProviderConfig(backupConfig, function (error) { if (error) return done(error); diff --git a/src/scripts/addmount.sh b/src/scripts/addmount.sh index 529d3d902..0ca55bb15 100755 --- a/src/scripts/addmount.sh +++ b/src/scripts/addmount.sh @@ -30,4 +30,9 @@ systemctl stop "${mount_filename}" || true echo "$mount_file_contents" > "${mount_file}" systemctl daemon-reload + +# systemd can automatically create the "where" dir but the backup logic relies on permissions +mkdir -p "${where}" +chown yellowtent:yellowtent "${where}" + systemctl enable --no-block --now "${mount_filename}" || true diff --git a/src/settings.js b/src/settings.js index 413dc7f48..391caabb2 100644 --- a/src/settings.js +++ b/src/settings.js @@ -393,14 +393,45 @@ function getBackupConfig(callback) { }); } +function mountOptionsChanged(currentConfig, backupConfig) { + return currentConfig.provider !== backupConfig.provider + || currentConfig.mountPoint !== backupConfig.mountPoint + || !_.isEqual(currentConfig.mountOptions, backupConfig.mountOptions); +} + function setBackupConfig(backupConfig, callback) { assert.strictEqual(typeof backupConfig, 'object'); assert.strictEqual(typeof callback, 'function'); - getBackupConfig(function (error, currentConfig) { + getBackupConfig(async function (error, oldConfig) { if (error) return callback(error); - backups.injectPrivateFields(backupConfig, currentConfig); + backups.injectPrivateFields(backupConfig, oldConfig); + + let oldMount = null, newMount = null; + if (backups.isMountProvider(oldConfig.provider)) { + oldMount = { + name: 'backup', + hostPath: oldConfig.mountPoint, + mountType: oldConfig.provider, + mountOptions: oldConfig.mountOptions + }; + } + + if (backups.isMountProvider(backupConfig.provider) && (!backups.isMountProvider(oldConfig.provider) || mountOptionsChanged(oldConfig, backupConfig))) { + error = mounts.validateMountOptions(backupConfig.provider, backupConfig.mountOptions); + if (error) return callback(error); + + newMount = { + name: 'backup', + hostPath: backupConfig.mountPoint, + mountType: backupConfig.provider, + mountOptions: backupConfig.mountOptions + }; + + [error] = await safe(mounts.tryAddMount(newMount, oldMount, { times: 20, interval: 500 })); // 10 seconds + if (error) return callback(error); + } backups.testConfig(backupConfig, async function (error) { if (error) return callback(error); @@ -411,31 +442,18 @@ function setBackupConfig(backupConfig, callback) { } // if any of these changes, we have to clear the cache - if ([ 'format', 'provider', 'prefix', 'bucket', 'region', 'endpoint', 'backupFolder', 'mountPoint', 'encryption' ].some(p => backupConfig[p] !== currentConfig[p])) { + if ([ 'format', 'provider', 'prefix', 'bucket', 'region', 'endpoint', 'backupFolder', 'mountPoint', 'encryption' ].some(p => backupConfig[p] !== oldConfig[p])) { debug('setBackupConfig: clearing backup cache'); backups.cleanupCacheFilesSync(); } - if (backupConfig.provider === 'sshfs' || backupConfig.provider === 'cifs' || backupConfig.provider === 'nfs' || backupConfig.provider === 'ext4') { - error = mounts.validateMountOptions(backupConfig.provider, backupConfig.mountOptions); + settingsdb.set(exports.BACKUP_CONFIG_KEY, JSON.stringify(backupConfig), async function (error) { if (error) return callback(error); - const backupVolume = { - name: 'backup', - hostPath: backupConfig.mountPoint, - mountType: backupConfig.provider, - mountOptions: backupConfig.mountOptions - }; - - [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) { - if (error) return callback(error); + if (oldMount && (!backups.isMountProvider(backupConfig.provider) || (newMount && newMount.hostPath !== oldMount.hostPath))) { + debug('setBackupConfig: removing old mount configuration'); + await safe(mounts.removeMountFile(oldMount.hostPath)); + } notifyChange(exports.BACKUP_CONFIG_KEY, backupConfig); diff --git a/src/storage/filesystem.js b/src/storage/filesystem.js index c634f8ab7..717684350 100644 --- a/src/storage/filesystem.js +++ b/src/storage/filesystem.js @@ -77,7 +77,7 @@ function checkPreconditions(apiConfig, dataLayout, callback) { df.file(getBackupPath(apiConfig)).then(function (result) { // Check filesystem is mounted so we don't write into the actual folder on disk - if (apiConfig.provider === PROVIDER_SSHFS || apiConfig.provider === PROVIDER_CIFS || apiConfig.provider === PROVIDER_NFS) { + if (apiConfig.provider === PROVIDER_SSHFS || apiConfig.provider === PROVIDER_CIFS || apiConfig.provider === PROVIDER_NFS || apiConfig.provider === PROVIDER_EXT4) { if (result.mountpoint !== apiConfig.mountPoint) return callback(new BoxError(BoxError.FS_ERROR, `${apiConfig.mountPoint} is not mounted`)); } else if (apiConfig.provider === PROVIDER_MOUNTPOINT) { if (result.mountpoint === '/') return callback(new BoxError(BoxError.FS_ERROR, `${apiConfig.backupFolder} is not mounted`)); @@ -287,32 +287,22 @@ function testConfig(apiConfig, callback) { if (path.isAbsolute(apiConfig.prefix)) return new BoxError(BoxError.BAD_FIELD, 'prefix must be a relative path', { field: 'backupFolder' }); if (path.normalize(apiConfig.prefix) !== apiConfig.prefix) return callback(new BoxError(BoxError.BAD_FIELD, 'prefix must contain a normalized relative path', { field: 'prefix' })); } - - const mounts = safe.fs.readFileSync('/proc/mounts', 'utf8'); - const mountInfo = mounts.split('\n').filter(function (l) { return l.indexOf(apiConfig.mountPoint) !== -1; })[0]; - if (!mountInfo) return callback(new BoxError(BoxError.BAD_FIELD, `${apiConfig.mountPoint} is not mounted`, { field: 'mountPoint' })); } - if (apiConfig.provider === PROVIDER_FILESYSTEM || apiConfig.provider === PROVIDER_MOUNTPOINT) { - const backupPath = getBackupPath(apiConfig); - const field = apiConfig.provider === PROVIDER_FILESYSTEM ? 'backupFolder' : '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 })); + if (!safe.fs.mkdirSync(path.join(backupPath, 'snapshot'), { recursive: true }) && 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.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.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 })); } callback(null);