diff --git a/src/mounts.js b/src/mounts.js index 6569c7b0e..11f245eaf 100644 --- a/src/mounts.js +++ b/src/mounts.js @@ -2,7 +2,7 @@ exports = module.exports = { tryAddMount, - removeMountFile, + removeMount, validateMountOptions, getStatus, }; @@ -56,6 +56,10 @@ function validateMountOptions(type, options) { } } +// https://www.man7.org/linux/man-pages/man8/mount.8.html for various mount option flags +// nfs - no_root_squash is mode on server to map all root to 'nobody' user. all_squash does this for all users (making it like ftp) +// sshfs - supports users/permissions +// cifs - does not support permissions function renderMountFile(volume) { assert.strictEqual(typeof volume, 'object'); @@ -80,13 +84,9 @@ function renderMountFile(volume) { break; case 'sshfs': { const keyFilePath = path.join(paths.SSHFS_KEYS_DIR, `id_rsa_${mountOptions.host}`); - - safe.fs.mkdirSync(paths.SSHFS_KEYS_DIR); - if (!safe.fs.writeFileSync(keyFilePath, `${mountOptions.privateKey}\n`, { mode: 0o600 })) throw new BoxError(BoxError.FS_ERROR, safe.error); - type = 'fuse.sshfs'; what= `${mountOptions.user}@${mountOptions.host}:${mountOptions.remoteDir}`; - options = `allow_other,port=${mountOptions.port},IdentityFile=${keyFilePath},reconnect`; + options = `allow_other,port=${mountOptions.port},IdentityFile=${keyFilePath},reconnect`; // allow_other mean non-root users can access it break; } case 'noop': // volume provider @@ -97,12 +97,19 @@ function renderMountFile(volume) { return ejs.render(SYSTEMD_MOUNT_EJS, { name, what, where: hostPath, options, type }); } -async function removeMountFile(hostPath) { - assert.strictEqual(typeof hostPath, 'string'); +async function removeMount(volume) { + assert.strictEqual(typeof volume, 'object'); + + const { hostPath, mountType, mountOptions } = volume; if (constants.TEST) return; - await safe(shell.promises.sudo('generateMountFile', [ RM_MOUNT_CMD, hostPath ], {})); // ignore any error + await safe(shell.promises.sudo('removeMount', [ RM_MOUNT_CMD, hostPath ], {})); // ignore any error + + if (mountType === 'sshfs') { + const keyFilePath = path.join(paths.SSHFS_KEYS_DIR, `id_rsa_${mountOptions.host}`); + safe.fs.unlinkSync(keyFilePath); + } } async function getStatus(mountType, hostPath) { @@ -137,30 +144,26 @@ async function getStatus(mountType, hostPath) { return { state, message }; } -async function tryAddMount(volume, oldVolume, options) { +async function tryAddMount(volume, options) { assert.strictEqual(typeof volume, 'object'); - assert.strictEqual(typeof oldVolume, 'object'); // can be null assert.strictEqual(typeof options, 'object'); if (volume.mountType === 'noop' || volume.mountType === 'mountpoint') return; // noop is from volume provider and mountpoint is from backup provider if (constants.TEST) return; - await shell.promises.sudo('addMount', [ ADD_MOUNT_CMD, renderMountFile(volume) ], {}); + if (volume.mountType === 'sshfs') { + const keyFilePath = path.join(paths.SSHFS_KEYS_DIR, `id_rsa_${volume.mountOptions.host}`); - const [error] = await safe(promiseRetry(options, async function () { - const status = await getStatus(volume.mountType, volume.hostPath); - if (status.state === 'active') return true; - throw new BoxError(BoxError.MOUNT_ERROR, `Mount is not active (${status.state}): ${status.message}`); - })); - - if (!error) return; // success - - // revert to old configuration - if (oldVolume) { - await safe(shell.promises.sudo('revertMount', [ ADD_MOUNT_CMD, renderMountFile(oldVolume) ], {})); - } else { - await removeMountFile(volume.hostPath); + safe.fs.mkdirSync(paths.SSHFS_KEYS_DIR); + if (!safe.fs.writeFileSync(keyFilePath, `${volume.mountOptions.privateKey}\n`, { mode: 0o600 })) throw new BoxError(BoxError.FS_ERROR, safe.error); + } + + await safe(shell.promises.sudo('addMount', [ ADD_MOUNT_CMD, renderMountFile(volume), options.timeout ], {})); + + const status = await getStatus(volume.mountType, volume.hostPath); + if (status.state !== 'active') { // cleanup + await removeMount(volume); + throw new BoxError(BoxError.MOUNT_ERROR, `Mount is not active (${status.state}): ${status.message}`); } - throw error; } diff --git a/src/routes/volumes.js b/src/routes/volumes.js index 40602764d..06ce0c672 100644 --- a/src/routes/volumes.js +++ b/src/routes/volumes.js @@ -6,7 +6,6 @@ exports = module.exports = { del, list, load, - update, getStatus }; @@ -63,19 +62,6 @@ async function list(req, res, next) { next(new HttpSuccess(200, { volumes: result })); } -async function update(req, res, next) { - assert.strictEqual(typeof req.params.id, 'string'); - - if (typeof req.body.mountType !== 'string') return next(new HttpError(400, 'mountType must be a string')); - if (typeof req.body.mountOptions !== 'object') return next(new HttpError(400, 'mountOptions must be a object')); - - req.clearTimeout(); // waiting for mount can take time - - const [error] = await safe(volumes.update(req.resource, req.body.mountType, req.body.mountOptions)); - if (error) return next(BoxError.toHttpError(error)); - next(new HttpSuccess(200, {})); -} - async function getStatus(req, res, next) { assert.strictEqual(typeof req.params.id, 'string'); diff --git a/src/scripts/addmount.sh b/src/scripts/addmount.sh index 1eb0cf7d7..70bc10fec 100755 --- a/src/scripts/addmount.sh +++ b/src/scripts/addmount.sh @@ -18,6 +18,7 @@ if [[ "$1" == "--check" ]]; then fi mount_file_contents="$1" +timeout="$2" # seconds # mount units must be named after the mount point directories they control where=$(echo "${mount_file_contents}" | grep "^Where=" | cut -d'=' -f 2) @@ -25,15 +26,22 @@ where=$(echo "${mount_file_contents}" | grep "^Where=" | cut -d'=' -f 2) mount_filename=$(systemd-escape -p --suffix=mount "$where") mount_file="/etc/systemd/system/${mount_filename}" -systemctl stop "${mount_filename}" || true +# cleanup any previous mount of same name (after midway box crash?) +if systemctl -q is-active mnt-volumes-ext4data.mount; then + echo "Previous mount active, unmounting" + systemctl stop "${mount_filename}" || true +fi 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}" || true # this can fail with nfs+root_squash -chmod 777 "${where}" # this allows all users to read and write +if ! timeout "${timeout}" systemctl enable --now "${mount_filename}"; then + echo "Failed to mount" + exit 1 +fi -systemctl enable --no-block --now "${mount_filename}" || true +echo "Mount succeeded" + +# this has to be done post-mount because permissions come from the underlying mount file system and not the mount point +chmod 777 "${where}" diff --git a/src/server.js b/src/server.js index 13bd58ef1..ae6696943 100644 --- a/src/server.js +++ b/src/server.js @@ -310,7 +310,6 @@ function initializeExpressSync() { router.get ('/api/v1/volumes', token, authorizeAdmin, routes.volumes.list); router.get ('/api/v1/volumes/:id', token, authorizeAdmin, routes.volumes.load, routes.volumes.get); router.del ('/api/v1/volumes/:id', token, authorizeAdmin, routes.volumes.load, routes.volumes.del); - router.post('/api/v1/volumes/:id', json, token, authorizeAdmin, routes.volumes.load, routes.volumes.update); router.get ('/api/v1/volumes/:id/status', token, authorizeAdmin, routes.volumes.load, routes.volumes.getStatus); router.use ('/api/v1/volumes/:id/files/*', token, authorizeAdmin, routes.filemanager.proxy); diff --git a/src/settings.js b/src/settings.js index e458ac814..40d9f1b9c 100644 --- a/src/settings.js +++ b/src/settings.js @@ -452,7 +452,7 @@ function setBackupConfig(backupConfig, callback) { if (oldMount && (!backups.isMountProvider(backupConfig.provider) || (newMount && newMount.hostPath !== oldMount.hostPath))) { debug('setBackupConfig: removing old mount configuration'); - await safe(mounts.removeMountFile(oldMount.hostPath)); + await safe(mounts.removeMount(oldMount)); } notifyChange(exports.BACKUP_CONFIG_KEY, backupConfig); diff --git a/src/volumes.js b/src/volumes.js index 6419256ed..ac3782267 100644 --- a/src/volumes.js +++ b/src/volumes.js @@ -5,7 +5,6 @@ exports = module.exports = { get, del, list, - update, getStatus, }; @@ -86,7 +85,7 @@ async function add(volume, auditSource) { const id = uuid.v4(); - if (volume.mountType !== 'noop') await mounts.tryAddMount(volume, null /* oldVolume */, { times: 20, interval: 500 }); // 10 seconds + if (volume.mountType !== 'noop') await mounts.tryAddMount(volume, { timeout: 10 }); // 10 seconds try { await database.query('INSERT INTO volumes (id, name, hostPath, mountType, mountOptionsJson) VALUES (?, ?, ?, ?, ?)', [ id, name, hostPath, mountType, JSON.stringify(mountOptions) ]); @@ -106,27 +105,6 @@ async function add(volume, auditSource) { return id; } -async function update(volume, mountType, mountOptions) { - assert.strictEqual(typeof volume, 'object'); - assert.strictEqual(typeof mountType, 'string'); - assert.strictEqual(typeof mountOptions, 'object'); - - let error = mounts.validateMountOptions(mountType, mountOptions); - if (error) throw error; - - if (mountType === 'noop') { - await mounts.removeMountFile(volume.hostPath); - } else { - await mounts.tryAddMount(Object.assign({}, volume, { mountType, mountOptions }), volume, { times: 20, interval: 500 }); // 10 seconds - } - - const result = await database.query('UPDATE volumes SET mountOptionsJson=? WHERE id=?', [ JSON.stringify(mountOptions), volume.id ]); - if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Volume not found'); - - // when diskPath or remoteDir is changed, we have to "rebind" the docker volumes - services.rebuildService('sftp', NOOP_CALLBACK); -} - async function getStatus(volume) { assert.strictEqual(typeof volume, 'object'); @@ -162,7 +140,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(mounts.removeMountFile(volume.hostPath)); + if (volume.mountType !== 'noop') await safe(mounts.removeMount(volume)); }); collectd.removeProfile(volume.id, NOOP_CALLBACK); }