'use strict'; exports = module.exports = { isMountProvider, tryAddMount, removeMount, validateMountOptions, getStatus, }; const assert = require('assert'), BoxError = require('./boxerror.js'), constants = require('./constants.js'), debug = require('debug')('box:mounts'), ejs = require('ejs'), fs = require('fs'), path = require('path'), paths = require('./paths.js'), 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 'filesystem': case 'mountpoint': 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 'sshfs': if (typeof options.user !== 'string') return new BoxError(BoxError.BAD_FIELD, 'user is not a string'); if (typeof options.privateKey !== 'string') return new BoxError(BoxError.BAD_FIELD, 'privateKey is not a string'); if (typeof options.port !== 'number') return new BoxError(BoxError.BAD_FIELD, 'port is not a number'); 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 mount type'); } } function isMountProvider(provider) { return provider === 'sshfs' || provider === 'cifs' || provider === 'nfs' || provider === 'ext4'; } // 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(mount) { assert.strictEqual(typeof mount, 'object'); const {name, hostPath, mountType, mountOptions} = mount; 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=0666,dir_mode=0777,uid=yellowtent,gid=yellowtent`; break; case 'nfs': type = 'nfs'; what = `${mountOptions.host}:${mountOptions.remoteDir}`; options = 'noauto'; // noauto means it is not a blocker for local-fs.target. _netdev is implicit. rw,hard,tcp,rsize=8192,wsize=8192,timeo=14 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': { const keyFilePath = path.join(paths.SSHFS_KEYS_DIR, `id_rsa_${mountOptions.host}`); type = 'fuse.sshfs'; what = `${mountOptions.user}@${mountOptions.host}:${mountOptions.remoteDir}`; options = `allow_other,port=${mountOptions.port},IdentityFile=${keyFilePath},StrictHostKeyChecking=no,reconnect`; // allow_other means non-root users can access it break; } case 'filesystem': case 'mountpoint': return; } return ejs.render(SYSTEMD_MOUNT_EJS, { name, what, where: hostPath, options, type }); } async function removeMount(mount) { assert.strictEqual(typeof mount, 'object'); const { hostPath, mountType, mountOptions } = mount; if (constants.TEST) return; await safe(shell.promises.sudo('removeMount', [ RM_MOUNT_CMD, hostPath ], {}), { debug }); // 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) { assert.strictEqual(typeof mountType, 'string'); assert.strictEqual(typeof hostPath, 'string'); if (mountType === 'filesystem') return { state: 'active', message: 'Mounted' }; const state = safe.child_process.execSync(`mountpoint -q -- ${hostPath}`) ? 'active' : 'inactive'; if (mountType === 'mountpoint') return { state, message: state === 'active' ? 'Mounted' : 'Not mounted' }; // we used to rely on "systemctl show -p ActiveState" output before but some mounts like sshfs.fuse show the status as "active" event though the mount commant failed (on ubuntu 18) let message; if (state !== 'active') { // find why it failed const logsJson = safe.child_process.execSync(`journalctl -u $(systemd-escape -p --suffix=mount ${hostPath}) -n 10 --no-pager -o json`, { encoding: 'utf8' }); if (logsJson) { const lines = logsJson.trim().split('\n').map(l => JSON.parse(l)); // array of json let start = -1, end = -1; // start and end of error message block for (let idx = lines.length - 1; idx >= 0; idx--) { // reverse const line = lines[idx]; const match = line['SYSLOG_IDENTIFIER'] === 'mount' || line['_EXE'].includes('mount') || line['_COMM'].includes('mount'); if (match) { if (end === -1) end = idx; start = idx; continue; } if (end !== -1) break; // no match and we already found a block } if (end !== -1) message = lines.slice(start, end+1).map(line => line['MESSAGE']).join('\n'); } if (!message) message = `Could not determine failure reason. ${safe.error ? safe.error.message : ''}`; } else { message = 'Mounted'; } return { state, message }; } async function tryAddMount(mount, options) { assert.strictEqual(typeof mount, 'object'); // { name, hostPath, mountType, mountOptions } assert.strictEqual(typeof options, 'object'); // { timeout, skipCleanup } if (mount.mountType === 'mountpoint') return; if (constants.TEST) return; if (mount.mountType === 'sshfs') { const keyFilePath = path.join(paths.SSHFS_KEYS_DIR, `id_rsa_${mount.mountOptions.host}`); safe.fs.mkdirSync(paths.SSHFS_KEYS_DIR); if (!safe.fs.writeFileSync(keyFilePath, `${mount.mountOptions.privateKey}\n`, { mode: 0o600 })) throw new BoxError(BoxError.FS_ERROR, safe.error); } const [error] = await safe(shell.promises.sudo('addMount', [ ADD_MOUNT_CMD, renderMountFile(mount), options.timeout ], {})); if (error && error.code === 2) throw new BoxError(BoxError.MOUNT_ERROR, 'Failed to unmount existing mount'); // at this point, the old mount config is still there if (options.skipCleanup) return; const status = await getStatus(mount.mountType, mount.hostPath); if (status.state !== 'active') { // cleanup await removeMount(mount); throw new BoxError(BoxError.MOUNT_ERROR, `Failed to mount (${status.state}): ${status.message}`); } }