21191bdc50
If the same host was mounted as volume and backup or as a temporary backup import, sharing the filename of the identify file would mean it will get removed while still in use
252 lines
12 KiB
JavaScript
252 lines
12 KiB
JavaScript
'use strict';
|
|
|
|
exports = module.exports = {
|
|
isManagedProvider,
|
|
tryAddMount,
|
|
removeMount,
|
|
validateMountOptions,
|
|
getStatus,
|
|
remount,
|
|
|
|
MOUNT_TYPE_FILESYSTEM: 'filesystem',
|
|
MOUNT_TYPE_MOUNTPOINT: 'mountpoint',
|
|
MOUNT_TYPE_CIFS: 'cifs',
|
|
MOUNT_TYPE_NFS: 'nfs',
|
|
MOUNT_TYPE_SSHFS: 'sshfs',
|
|
MOUNT_TYPE_EXT4: 'ext4', // raw disk path
|
|
MOUNT_TYPE_XFS: 'xfs', // raw disk path
|
|
MOUNT_TYPE_DISK: 'disk', // this provides a selector of block devices
|
|
};
|
|
|
|
const assert = require('node:assert'),
|
|
BoxError = require('./boxerror.js'),
|
|
constants = require('./constants.js'),
|
|
debug = require('debug')('box:mounts'),
|
|
ejs = require('ejs'),
|
|
fs = require('node:fs'),
|
|
path = require('node:path'),
|
|
paths = require('./paths.js'),
|
|
safe = require('safetydance'),
|
|
shell = require('./shell.js')('mounts');
|
|
|
|
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
|
|
async function validateMountOptions(type, options) {
|
|
assert.strictEqual(typeof type, 'string');
|
|
assert.strictEqual(typeof options, 'object');
|
|
|
|
switch (type) {
|
|
case exports.MOUNT_TYPE_FILESYSTEM:
|
|
case exports.MOUNT_TYPE_MOUNTPOINT:
|
|
if (typeof options.hostPath !== 'string') return new BoxError(BoxError.BAD_FIELD, 'hostPath is not a string');
|
|
return null;
|
|
case exports.MOUNT_TYPE_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 exports.MOUNT_TYPE_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 exports.MOUNT_TYPE_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 exports.MOUNT_TYPE_EXT4:
|
|
case exports.MOUNT_TYPE_XFS:
|
|
case exports.MOUNT_TYPE_DISK: {
|
|
if (typeof options.diskPath !== 'string') return new BoxError(BoxError.BAD_FIELD, 'diskPath is not a string');
|
|
const [error, output] = await safe(shell.spawn('lsblk', ['--paths', '--json', '--list', '--fs', options.diskPath ], { encoding: 'utf8' }));
|
|
if (error) return new BoxError(BoxError.BAD_FIELD, `Bad disk path: ${error.message}`);
|
|
const info = safe.JSON.parse(output);
|
|
if (!info) return new BoxError(BoxError.BAD_FIELD, `Bad disk path: ${safe.error.message}`);
|
|
for (const mountpoint of info.blockdevices[0].mountpoints) {
|
|
if (mountpoint === null) break; // [ null ] means not mounted anywhere
|
|
if (mountpoint === '/' || mountpoint.startsWith('/home') || mountpoint.startsWith('/boot')) return new BoxError(BoxError.BAD_FIELD, 'Disk is mounted in a protected location');
|
|
}
|
|
return null;
|
|
}
|
|
default:
|
|
return new BoxError(BoxError.BAD_FIELD, 'Bad mount type');
|
|
}
|
|
}
|
|
|
|
// managed providers are those for which we setup systemd mount file under /mnt/volumes
|
|
function isManagedProvider(provider) {
|
|
switch (provider) {
|
|
case exports.MOUNT_TYPE_SSHFS:
|
|
case exports.MOUNT_TYPE_CIFS:
|
|
case exports.MOUNT_TYPE_NFS:
|
|
case exports.MOUNT_TYPE_EXT4:
|
|
case exports.MOUNT_TYPE_XFS:
|
|
case exports.MOUNT_TYPE_DISK:
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// 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
|
|
async function renderMountFile(mount) {
|
|
assert.strictEqual(typeof mount, 'object');
|
|
|
|
const { description, hostPath, mountType, mountOptions } = mount;
|
|
|
|
let options, what, type, dependsOn;
|
|
switch (mountType) {
|
|
case exports.MOUNT_TYPE_CIFS: {
|
|
const out = await shell.spawn('systemd-escape', [ '-p', hostPath ], { encoding: 'utf8' }); // this ensures uniqueness of creds file
|
|
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`;
|
|
dependsOn = 'network-online.target';
|
|
break;
|
|
}
|
|
case exports.MOUNT_TYPE_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
|
|
dependsOn = 'network-online.target';
|
|
break;
|
|
case exports.MOUNT_TYPE_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 exports.MOUNT_TYPE_XFS:
|
|
type = 'xfs';
|
|
what = mountOptions.diskPath; // like /dev/disk/by-uuid/uuid or /dev/disk/by-id/scsi-id
|
|
options = 'discard,defaults,noatime,pquota';
|
|
break;
|
|
case exports.MOUNT_TYPE_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 exports.MOUNT_TYPE_SSHFS: {
|
|
const keyFilePath = path.join(paths.SSHFS_KEYS_DIR, `identity_file_${path.basename(hostPath)}`);
|
|
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
|
|
dependsOn = 'network-online.target';
|
|
break;
|
|
}
|
|
case exports.MOUNT_TYPE_FILESYSTEM:
|
|
case exports.MOUNT_TYPE_MOUNTPOINT:
|
|
return;
|
|
}
|
|
|
|
return ejs.render(SYSTEMD_MOUNT_EJS, { description, what, where: hostPath, options, type, dependsOn });
|
|
}
|
|
|
|
async function removeMount(mount) {
|
|
assert.strictEqual(typeof mount, 'object');
|
|
|
|
const { hostPath, mountType } = mount;
|
|
|
|
if (constants.TEST) return;
|
|
|
|
await safe(shell.sudo([ RM_MOUNT_CMD, hostPath ], {}), { debug }); // ignore any error
|
|
|
|
if (mountType === exports.MOUNT_TYPE_SSHFS) {
|
|
const keyFilePath = path.join(paths.SSHFS_KEYS_DIR, `identity_file_${path.basename(hostPath)}`);
|
|
safe.fs.unlinkSync(keyFilePath);
|
|
} else if (mountType === exports.MOUNT_TYPE_CIFS) {
|
|
const out = await shell.spawn('systemd-escape', [ '-p', hostPath ], { encoding: 'utf8' });
|
|
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 === exports.MOUNT_TYPE_FILESYSTEM) {
|
|
const exists = safe.fs.existsSync(hostPath);
|
|
return { state: exists ? 'active' : 'inactive', message: exists ? '' : `${hostPath} not found: ${safe.error.message}` };
|
|
}
|
|
|
|
const [error] = await safe(shell.spawn('mountpoint', [ '-q', '--', hostPath ], { timeout: 5000, encoding: 'utf8' }));
|
|
const state = error ? 'inactive' : 'active';
|
|
|
|
if (mountType === 'mountpoint') return { state, message: state === 'active' ? '' : '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 unitName = await shell.spawn('systemd-escape', ['-p', '--suffix=mount', hostPath], { encoding: 'utf8' });
|
|
const logsJson = await shell.spawn('journalctl', ['-u', unitName, '-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 : ''}`;
|
|
}
|
|
|
|
return { state, message };
|
|
}
|
|
|
|
async function tryAddMount(mount, options) {
|
|
assert.strictEqual(typeof mount, 'object'); // { description, hostPath, mountType, mountOptions }
|
|
assert.strictEqual(typeof options, 'object'); // { timeout, skipCleanup }
|
|
|
|
assert(isManagedProvider(mount.mountType));
|
|
|
|
if (constants.TEST) return;
|
|
|
|
const mountFileContents = await renderMountFile(mount);
|
|
const [error] = await safe(shell.sudo([ ADD_MOUNT_CMD, mountFileContents, 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(hostPath) {
|
|
assert.strictEqual(typeof hostPath, 'string');
|
|
|
|
if (constants.TEST) return;
|
|
|
|
const [error] = await safe(shell.sudo([ REMOUNT_MOUNT_CMD, 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
|
|
}
|