diff --git a/src/mounts.js b/src/mounts.js new file mode 100644 index 000000000..67c980e07 --- /dev/null +++ b/src/mounts.js @@ -0,0 +1,116 @@ +'use strict'; + +exports = module.exports = { + writeMountFile, + removeMountFile, + validateMountOptions, + getStatus +}; + +const assert = require('assert'), + BoxError = require('./boxerror.js'), + ejs = require('ejs'), + fs = require('fs'), + path = require('path'), + safe = require('safetydance'), + shell = require('./shell.js'); + +const ADD_MOUNT_CMD = path.join(__dirname, 'scripts/addmount.sh'); +const RM_MOUNT_CMD = path.join(__dirname, 'scripts/rmmount.sh'); +const SYSTEMD_MOUNT_EJS = fs.readFileSync(path.join(__dirname, 'systemd-mount.ejs'), { encoding: 'utf8' }); + +// https://man7.org/linux/man-pages/man8/mount.8.html +function validateMountOptions(type, options) { + assert.strictEqual(typeof type, 'string'); + assert.strictEqual(typeof options, 'object'); + + switch (type) { + case 'noop': + return null; + case 'cifs': + if (typeof options.username !== 'string') return new BoxError(BoxError.BAD_FIELD, 'username is not a string'); + if (typeof options.password !== 'string') return new BoxError(BoxError.BAD_FIELD, 'password is not a string'); + if (typeof options.host !== 'string') return new BoxError(BoxError.BAD_FIELD, 'host is not a string'); + if (typeof options.remoteDir !== 'string') return new BoxError(BoxError.BAD_FIELD, 'remoteDir is not a string'); + return null; + case 'nfs': + if (typeof options.host !== 'string') return new BoxError(BoxError.BAD_FIELD, 'host is not a string'); + if (typeof options.remoteDir !== 'string') return new BoxError(BoxError.BAD_FIELD, 'remoteDir is not a string'); + return null; + case 'ext4': + if (typeof options.diskPath !== 'string') return new BoxError(BoxError.BAD_FIELD, 'diskPath is not a string'); + return null; + default: + return new BoxError(BoxError.BAD_FIELD, 'Bad volume mount type'); + } +} + +async function writeMountFile(volume) { + assert.strictEqual(typeof volume, 'object'); + + const {name, hostPath, mountType, mountOptions} = volume; + + let options, what, type; + switch (mountType) { + case 'cifs': + type = 'cifs'; + what = `//${mountOptions.host}` + path.join('/', mountOptions.remoteDir); + options = `username=${mountOptions.username},password=${mountOptions.password},rw,iocharset=utf8,file_mode=0777,dir_mode=0777,uid=yellowtent,gid=yellowtent`; + break; + case 'nfs': + type = 'nfs'; + what = `${mountOptions.host}:${mountOptions.remoteDir}`; + options = 'noauto,x-systemd.automount'; // _netdev is implicit + break; + case 'ext4': + type = 'ext4'; + what = mountOptions.diskPath; // like /dev/disk/by-uuid/uuid or /dev/disk/by-id/scsi-id + options = 'discard,defaults,noatime'; + break; + case 'sshfs': + // type = 'sshfs'; + // What={{ USER }}@{{ HOST }}:{{ REMOTE DIR }} + // Options=_netdev,allow_other,IdentityFile=/home/{{ MY LOCAL USER WITH SSH KEY IN ITS HOME DIRECTORY }}/.ssh/id_rsa,reconnect,x-systemd.automount,uid=1000,gid=1000 + } + + const mountFileContents = ejs.render(SYSTEMD_MOUNT_EJS, { name, what, where: hostPath, options, type }); + await shell.promises.sudo('generateMountFile', [ ADD_MOUNT_CMD, mountFileContents ], {}); +} + +async function removeMountFile(hostPath) { + assert.strictEqual(typeof hostPath, 'string'); + + await safe(shell.promises.sudo('generateMountFile', [ RM_MOUNT_CMD, hostPath ], {})); // ignore any error +} + +async function getStatus(mountType, hostPath) { + assert.strictEqual(typeof mountType, 'string'); + assert.strictEqual(typeof hostPath, 'string'); + + if (mountType === 'noop') { + if (safe.child_process.execSync(`mountpoint -q -- ${hostPath}`, { encoding: 'utf8' })) { + return { state: 'active', message: 'Mounted' }; + } else { + return { state: 'inactive', message: 'Not mounted' }; + } + } + + let output = safe.child_process.execSync(`systemctl show --value -p ActiveState $(systemd-escape -p --suffix=mount ${hostPath})`, { encoding: 'utf8' }); + let state = output ? output.trim() : ''; + let message; + + if (state !== 'active') { // find why it failed + output = safe.child_process.execSync(`journalctl -u $(systemd-escape -p --suffix=mount ${hostPath}) -n 10 --no-pager -o cat`, { encoding: 'utf8' }); + + if (output) { + const rlines = output.split('\n').reverse(); + 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}`; + } else { + message = 'Mounted'; + } + + return { state, message }; +} diff --git a/src/routes/settings.js b/src/routes/settings.js index 38ba3b352..8b43d7c2f 100644 --- a/src/routes/settings.js +++ b/src/routes/settings.js @@ -8,7 +8,7 @@ exports = module.exports = { setBackupConfig }; -var assert = require('assert'), +const assert = require('assert'), backups = require('../backups.js'), BoxError = require('../boxerror.js'), docker = require('../docker.js'), @@ -106,6 +106,9 @@ 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 req.clearTimeout(); diff --git a/src/settings.js b/src/settings.js index 3ac2f835a..ab388e9af 100644 --- a/src/settings.js +++ b/src/settings.js @@ -131,6 +131,8 @@ const assert = require('assert'), docker = require('./docker.js'), 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'), @@ -378,11 +380,17 @@ function setUnstableAppsConfig(enabled, callback) { function getBackupConfig(callback) { assert.strictEqual(typeof callback, 'function'); - settingsdb.get(exports.BACKUP_CONFIG_KEY, function (error, value) { + settingsdb.get(exports.BACKUP_CONFIG_KEY, async function (error, value) { if (error && error.reason === BoxError.NOT_FOUND) return callback(null, gDefaults[exports.BACKUP_CONFIG_KEY]); if (error) return callback(error); - callback(null, JSON.parse(value)); // provider, token, password, region, prefix, bucket + 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 } + } + + callback(null, backupConfig); }); } @@ -395,7 +403,7 @@ function setBackupConfig(backupConfig, callback) { backups.injectPrivateFields(backupConfig, currentConfig); - backups.testConfig(backupConfig, function (error) { + backups.testConfig(backupConfig, async function (error) { if (error) return callback(error); if ('password' in backupConfig) { // user set password @@ -409,6 +417,21 @@ function setBackupConfig(backupConfig, callback) { backups.cleanupCacheFilesSync(); } + if ('mountType' in backupConfig) { + error = mounts.validateMountOptions(backupConfig.mountType, backupConfig.mountOptions); + if (error) return callback(error); + + const backupVolume = { + name: 'backup', + hostPath: backupConfig.mountPoint, + mountType: backupConfig.mountType, + mountOptions: backupConfig.mountOptions + }; + + [error] = await safe(backupConfig.mountType === 'noop' ? mounts.removeMountFile(backupVolume.hostPath) : mounts.writeMountFile(backupVolume)); + if (error) return callback(error); + } + settingsdb.set(exports.BACKUP_CONFIG_KEY, JSON.stringify(backupConfig), function (error) { if (error) return callback(error); diff --git a/src/volumes.js b/src/volumes.js index e68c1e5fe..fcd462921 100644 --- a/src/volumes.js +++ b/src/volumes.js @@ -17,16 +17,13 @@ const assert = require('assert'), ejs = require('ejs'), eventlog = require('./eventlog.js'), fs = require('fs'), + mounts = require('./mounts.js'), path = require('path'), safe = require('safetydance'), services = require('./services.js'), - shell = require('./shell.js'), uuid = require('uuid'); const VOLUMES_FIELDS = [ 'id', 'name', 'hostPath', 'creationTime', 'mountType', 'mountOptionsJson' ].join(','); -const ADD_MOUNT_CMD = path.join(__dirname, 'scripts/addmount.sh'); -const RM_MOUNT_CMD = path.join(__dirname, 'scripts/rmmount.sh'); -const SYSTEMD_MOUNT_EJS = fs.readFileSync(path.join(__dirname, 'systemd-mount.ejs'), { encoding: 'utf8' }); const COLLECTD_CONFIG_EJS = fs.readFileSync(__dirname + '/collectd/volume.ejs', { encoding: 'utf8' }); const NOOP_CALLBACK = function (error) { if (error) debug(error); }; @@ -48,32 +45,6 @@ function validateName(name) { return null; } -// https://man7.org/linux/man-pages/man8/mount.8.html -function validateMountOptions(type, options) { - assert.strictEqual(typeof type, 'string'); - assert.strictEqual(typeof options, 'object'); - - switch (type) { - case 'noop': - return null; - case 'cifs': - if (typeof options.username !== 'string') return new BoxError(BoxError.BAD_FIELD, 'username is not a string'); - if (typeof options.password !== 'string') return new BoxError(BoxError.BAD_FIELD, 'password is not a string'); - if (typeof options.host !== 'string') return new BoxError(BoxError.BAD_FIELD, 'host is not a string'); - if (typeof options.remoteDir !== 'string') return new BoxError(BoxError.BAD_FIELD, 'remoteDir is not a string'); - return null; - case 'nfs': - if (typeof options.host !== 'string') return new BoxError(BoxError.BAD_FIELD, 'host is not a string'); - if (typeof options.remoteDir !== 'string') return new BoxError(BoxError.BAD_FIELD, 'remoteDir is not a string'); - return null; - case 'ext4': - if (typeof options.diskPath !== 'string') return new BoxError(BoxError.BAD_FIELD, 'diskPath is not a string'); - return null; - default: - return new BoxError(BoxError.BAD_FIELD, 'Bad volume mount type'); - } -} - function validateHostPath(hostPath, mountType) { assert.strictEqual(typeof hostPath, 'string'); assert.strictEqual(typeof mountType, 'string'); @@ -97,56 +68,18 @@ function validateHostPath(hostPath, mountType) { return null; } -async function writeMountFile(volume) { - assert.strictEqual(typeof volume, 'object'); - - const {name, hostPath, mountType, mountOptions} = volume; - - let options, what, type; - switch (mountType) { - case 'cifs': - type = 'cifs'; - what = `//${mountOptions.host}` + path.join('/', mountOptions.remoteDir); - options = `username=${mountOptions.username},password=${mountOptions.password},rw,iocharset=utf8,file_mode=0777,dir_mode=0777,uid=yellowtent,gid=yellowtent`; - break; - case 'nfs': - type = 'nfs'; - what = `${mountOptions.host}:${mountOptions.remoteDir}`; - options = 'noauto,x-systemd.automount'; // _netdev is implicit - break; - case 'ext4': - type = 'ext4'; - what = mountOptions.diskPath; // like /dev/disk/by-uuid/uuid or /dev/disk/by-id/scsi-id - options = 'discard,defaults,noatime'; - break; - case 'sshfs': - // type = 'sshfs'; - // What={{ USER }}@{{ HOST }}:{{ REMOTE DIR }} - // Options=_netdev,allow_other,IdentityFile=/home/{{ MY LOCAL USER WITH SSH KEY IN ITS HOME DIRECTORY }}/.ssh/id_rsa,reconnect,x-systemd.automount,uid=1000,gid=1000 - } - - const mountFileContents = ejs.render(SYSTEMD_MOUNT_EJS, { name, what, where: hostPath, options, type }); - await shell.promises.sudo('generateMountFile', [ ADD_MOUNT_CMD, mountFileContents ], {}); -} - -async function removeMountFile(volume) { - assert.strictEqual(typeof volume, 'object'); - - await safe(shell.promises.sudo('generateMountFile', [ RM_MOUNT_CMD, volume.hostPath ], {})); // ignore any error -} - async function update(volume, mountType, mountOptions) { assert.strictEqual(typeof volume, 'object'); assert.strictEqual(typeof mountType, 'string'); assert.strictEqual(typeof mountOptions, 'object'); - let error = validateMountOptions(mountType, mountOptions); + let error = mounts.validateMountOptions(mountType, mountOptions); if (error) throw error; if (mountType === 'noop') { - await removeMountFile(Object.assign({}, volume, { mountType, mountOptions })); + await mounts.removeMountFile(volume.hostPath); } else { - await writeMountFile(Object.assign({}, volume, { mountType, mountOptions })); + await mounts.writeMountFile(Object.assign({}, volume, { mountType, mountOptions })); } let result; @@ -158,32 +91,7 @@ async function update(volume, mountType, mountOptions) { async function getStatus(volume) { assert.strictEqual(typeof volume, 'object'); - if (volume.mountType === 'noop') { - if (safe.child_process.execSync(`mountpoint -q -- ${volume.hostPath}`, { encoding: 'utf8' })) { - return { state: 'active', message: 'Mounted' }; - } else { - return { state: 'inactive', message: 'Not mounted' }; - } - } - - let output = safe.child_process.execSync(`systemctl show --value -p ActiveState $(systemd-escape -p --suffix=mount ${volume.hostPath})`, { encoding: 'utf8' }); - let state = output ? output.trim() : ''; - let message; - - if (state !== 'active') { // find why it failed - output = safe.child_process.execSync(`journalctl -u $(systemd-escape -p --suffix=mount ${volume.hostPath}) -n 10 --no-pager -o cat`, { encoding: 'utf8' }); - - if (output) { - const rlines = output.split('\n').reverse(); - 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}`; - } else { - message = 'Mounted'; - } - - return { state, message }; + return await mounts.getStatus(volume.mountType, volume.hostPath); // { state, message } } async function add(volume, auditSource) { @@ -198,13 +106,13 @@ async function add(volume, auditSource) { error = validateHostPath(hostPath, mountType); if (error) throw error; - error = validateMountOptions(mountType, mountOptions); + error = mounts.validateMountOptions(mountType, mountOptions); if (error) throw error; const id = uuid.v4(); try { - if (mountType !== 'noop') await writeMountFile(volume); + if (mountType !== 'noop') await mounts.writeMountFile(volume); await database.query('INSERT INTO volumes (id, name, hostPath, mountType, mountOptionsJson) VALUES (?, ?, ?, ?, ?)', [ id, name, hostPath, mountType, JSON.stringify(mountOptions) ]); } catch (error) { if (error.code === 'ER_DUP_ENTRY' && error.sqlMessage.indexOf('name') !== -1) throw new BoxError(BoxError.ALREADY_EXISTS, 'name already exists'); @@ -251,7 +159,7 @@ async function del(volume, auditSource) { eventlog.add(eventlog.ACTION_VOLUME_REMOVE, auditSource, { volume }); services.rebuildService('sftp', async function () { - if (volume.mountType !== 'noop') await safe(removeMountFile(volume)); + if (volume.mountType !== 'noop') await safe(mounts.removeMountFile(volume.hostPath)); }); collectd.removeProfile(volume.id, NOOP_CALLBACK); }