Files
cloudron-box/src/mounts.js

221 lines
10 KiB
JavaScript
Raw Normal View History

2021-05-14 15:07:29 -07:00
'use strict';
exports = module.exports = {
2022-01-26 12:40:28 -08:00
isManagedProvider,
tryAddMount,
removeMount,
2021-05-14 15:07:29 -07:00
validateMountOptions,
getStatus,
2021-10-11 15:51:16 +02:00
remount
2021-05-14 15:07:29 -07:00
};
const assert = require('assert'),
BoxError = require('./boxerror.js'),
2021-05-17 16:23:24 -07:00
constants = require('./constants.js'),
2021-09-29 20:15:54 -07:00
debug = require('debug')('box:mounts'),
2021-05-14 15:07:29 -07:00
ejs = require('ejs'),
fs = require('fs'),
path = require('path'),
2021-05-18 16:49:39 +02:00
paths = require('./paths.js'),
2021-05-14 15:07:29 -07:00
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');
2021-10-11 15:51:16 +02:00
const REMOUNT_MOUNT_CMD = path.join(__dirname, 'scripts/remountmount.sh');
2024-02-29 11:51:57 +01:00
const SYSTEMD_MOUNT_EJS = fs.readFileSync(path.join(__dirname, 'systemd-mount.ejs'), { encoding: 'utf8' });
2021-05-14 15:07:29 -07:00
// 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');
2021-05-14 15:07:29 -07:00
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');
2021-05-14 15:07:29 -07:00
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;
2021-05-18 16:49:39 +02:00
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;
2021-05-14 15:07:29 -07:00
case 'ext4':
2022-06-08 10:32:25 -07:00
case 'xfs':
case 'disk':
2021-05-14 15:07:29 -07:00
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');
2021-05-14 15:07:29 -07:00
}
}
// managed providers are those for which we setup systemd mount file under /mnt/volumes
2022-01-26 12:40:28 -08:00
function isManagedProvider(provider) {
return provider === 'sshfs' || provider === 'cifs' || provider === 'nfs' || provider === 'ext4' || provider === 'xfs' || provider === 'disk';
2021-07-14 11:07:19 -07:00
}
// 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
2024-02-20 23:09:49 +01:00
async function renderMountFile(mount) {
assert.strictEqual(typeof mount, 'object');
2021-05-14 15:07:29 -07:00
const { name, hostPath, mountType, mountOptions } = mount;
2021-05-14 15:07:29 -07:00
let options, what, type;
switch (mountType) {
case 'cifs': {
2024-02-29 10:41:07 +01:00
const out = await shell.execArgs('renderMountFile', 'systemd-escape', [ '-p', hostPath ], {}); // 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}`);
2021-05-14 15:07:29 -07:00
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`;
2021-05-14 15:07:29 -07:00
break;
}
2021-05-14 15:07:29 -07:00
case 'nfs':
type = 'nfs';
what = `${mountOptions.host}:${mountOptions.remoteDir}`;
2021-06-18 23:48:39 -07:00
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
2021-05-14 15:07:29 -07:00
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;
2022-06-08 10:32:25 -07:00
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
2022-06-08 10:32:25 -07:00
options = 'discard,defaults,noatime';
break;
case 'sshfs': {
2021-05-18 16:49:39 +02:00
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}`);
2021-05-18 16:49:39 +02:00
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
2021-05-18 16:49:39 +02:00
break;
2021-05-14 15:07:29 -07:00
}
case 'filesystem':
case 'mountpoint':
return;
}
2021-05-14 15:07:29 -07:00
return ejs.render(SYSTEMD_MOUNT_EJS, { name, what, where: hostPath, options, type });
2021-05-14 15:07:29 -07:00
}
async function removeMount(mount) {
assert.strictEqual(typeof mount, 'object');
const { hostPath, mountType, mountOptions } = mount;
2021-05-14 15:07:29 -07:00
2021-05-17 16:23:24 -07:00
if (constants.TEST) return;
2021-09-29 20:15:54 -07:00
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') {
2024-02-29 09:00:22 +01:00
const out = await shell.execArgs('removeMount', 'systemd-escape', [ '-p', hostPath ], {});
const credentialsFilePath = path.join(paths.CIFS_CREDENTIALS_DIR, `${out.trim()}.cred`);
safe.fs.unlinkSync(credentialsFilePath);
}
2021-05-14 15:07:29 -07:00
}
async function getStatus(mountType, hostPath) {
assert.strictEqual(typeof mountType, 'string');
assert.strictEqual(typeof hostPath, 'string');
if (mountType === 'filesystem') return { state: 'active', message: 'Mounted' };
2024-02-29 09:00:22 +01:00
const [error] = await safe(shell.execArgs('getVolumeStatus', 'mountpoint', [ '-q', '--', hostPath ], { timeout: 5000 }));
const state = error ? 'inactive' : 'active';
if (mountType === 'mountpoint') return { state, message: state === 'active' ? 'Mounted' : 'Not mounted' };
2021-05-14 15:07:29 -07:00
// 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)
2021-05-14 15:07:29 -07:00
let message;
if (state !== 'active') { // find why it failed
2024-02-28 20:37:11 +01:00
const logsJson = await shell.exec('getStatus', `journalctl -u $(systemd-escape -p --suffix=mount ${hostPath}) -n 10 --no-pager -o json`, { shell: '/bin/bash' });
2021-07-09 16:59:57 -07:00
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'));
2021-07-09 16:59:57 -07:00
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');
2021-05-14 15:07:29 -07:00
}
2022-10-02 16:38:12 +02:00
if (!message) message = `Could not determine mount failure reason. ${safe.error ? safe.error.message : ''}`;
2021-05-14 15:07:29 -07:00
} 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 }
2022-11-03 23:28:02 +01:00
if (mount.mountType === 'mountpoint' || mount.mountType === 'filesystem') return;
if (constants.TEST) return;
2024-02-20 23:09:49 +01:00
const mountFileContents = await renderMountFile(mount);
const [error] = await safe(shell.promises.sudo('addMount', [ 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);
2021-06-21 22:37:32 -07:00
throw new BoxError(BoxError.MOUNT_ERROR, `Failed to mount (${status.state}): ${status.message}`);
}
}
2021-10-11 15:51:16 +02:00
async function remount(mount) {
assert.strictEqual(typeof mount, 'object'); // { name, hostPath, mountType, mountOptions }
2022-11-03 23:28:02 +01:00
if (mount.mountType === 'mountpoint' || mount.mountType === 'filesystem') return;
2021-10-11 15:51:16 +02:00
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
}