64381e2a04
this also moves out the attempt validation logic from mounts code into volumes. mounts.tryAddMount is also used in backup code
220 lines
10 KiB
JavaScript
220 lines
10 KiB
JavaScript
'use strict';
|
|
|
|
exports = module.exports = {
|
|
isManagedProvider,
|
|
tryAddMount,
|
|
removeMount,
|
|
validateMountOptions,
|
|
getStatus,
|
|
remount
|
|
};
|
|
|
|
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 REMOUNT_MOUNT_CMD = path.join(__dirname, 'scripts/remountmount.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':
|
|
if (typeof options.hostPath !== 'string') return new BoxError(BoxError.BAD_FIELD, 'hostPath is not a string');
|
|
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');
|
|
if ('seal' in options && typeof options.seal !== 'boolean') return new BoxError(BoxError.BAD_FIELD, 'seal is not a boolean');
|
|
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':
|
|
case 'xfs':
|
|
case 'disk':
|
|
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 isManagedProvider(provider) {
|
|
return provider === 'sshfs' || provider === 'cifs' || provider === 'nfs' || provider === 'ext4' || provider === 'xfs' || provider === 'disk';
|
|
}
|
|
|
|
// 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': {
|
|
const out = safe.child_process.execSync(`systemd-escape -p '${hostPath}'`, { encoding: 'utf8' }); // this ensures uniqueness of creds file
|
|
if (!out) throw new BoxError(BoxError.FS_ERROR, `Could not determine credentials file name: ${safe.error.message}`);
|
|
const credentialsFilePath = path.join(paths.CIFS_CREDENTIALS_DIR, `${out.trim()}.cred`);
|
|
if (!safe.fs.writeFileSync(credentialsFilePath, `username=${mountOptions.username}\npassword=${mountOptions.password}\n`, { mode: 0o600 })) throw new BoxError(BoxError.FS_ERROR, `Could not write credentials file: ${safe.error.message}`);
|
|
|
|
type = 'cifs';
|
|
what = `//${mountOptions.host}` + path.join('/', mountOptions.remoteDir);
|
|
options = `credentials=${credentialsFilePath},rw,${mountOptions.seal ? 'seal,' : ''}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 'xfs':
|
|
type = 'xfs';
|
|
what = mountOptions.diskPath; // like /dev/disk/by-uuid/uuid or /dev/disk/by-id/scsi-id
|
|
options = 'discard,defaults,noatime';
|
|
break;
|
|
case 'disk':
|
|
type = 'auto';
|
|
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}`);
|
|
if (!safe.fs.writeFileSync(keyFilePath, `${mount.mountOptions.privateKey}\n`, { mode: 0o600 })) throw new BoxError(BoxError.FS_ERROR, `Could not write private key: ${safe.error.message}`);
|
|
|
|
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);
|
|
} else if (mountType === 'cifs') {
|
|
const out = safe.child_process.execSync(`systemd-escape -p '${hostPath}'`, { encoding: 'utf8' });
|
|
if (!out) return;
|
|
const credentialsFilePath = path.join(paths.CIFS_CREDENTIALS_DIR, `${out.trim()}.cred`);
|
|
safe.fs.unlinkSync(credentialsFilePath);
|
|
}
|
|
}
|
|
|
|
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'] && line['_EXE'].includes('mount')) || (line['_COMM'] && 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 mount 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' || mount.mountType === 'filesystem') return;
|
|
|
|
if (constants.TEST) return;
|
|
|
|
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}`);
|
|
}
|
|
}
|
|
|
|
async function remount(mount) {
|
|
assert.strictEqual(typeof mount, 'object'); // { name, hostPath, mountType, mountOptions }
|
|
|
|
if (mount.mountType === 'mountpoint' || mount.mountType === 'filesystem') return;
|
|
|
|
if (constants.TEST) return;
|
|
|
|
const [error] = await safe(shell.promises.sudo('remountMount', [ REMOUNT_MOUNT_CMD, mount.hostPath ], {}));
|
|
if (error && error.code === 2) throw new BoxError(BoxError.MOUNT_ERROR, 'Failed to remount existing mount'); // at this point, the old mount config is still there
|
|
}
|