Files
cloudron-box/src/mounts.js
T
2021-06-22 15:52:50 -07:00

171 lines
7.5 KiB
JavaScript

'use strict';
exports = module.exports = {
tryAddMount,
removeMount,
validateMountOptions,
getStatus,
};
const assert = require('assert'),
BoxError = require('./boxerror.js'),
constants = require('./constants.js'),
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 'noop': // volume provider
case 'mountpoint': // backup provider
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 volume mount type');
}
}
// 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');
const {name, hostPath, mountType, mountOptions} = volume;
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},reconnect`; // allow_other mean non-root users can access it
break;
}
case 'noop': // volume provider
case 'mountpoint': // backup provider
return;
}
return ejs.render(SYSTEMD_MOUNT_EJS, { name, what, where: hostPath, options, type });
}
async function removeMount(volume) {
assert.strictEqual(typeof volume, 'object');
const { hostPath, mountType, mountOptions } = volume;
if (constants.TEST) return;
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) {
assert.strictEqual(typeof mountType, 'string');
assert.strictEqual(typeof hostPath, 'string');
if (mountType === 'noop' || mountType === 'mountpoint') { // noop is from volume provider and mountpoint is from backup provider
safe.child_process.execSync(`mountpoint -q -- ${hostPath}`, { encoding: 'utf8' });
if (!safe.error) {
return { state: 'active', message: 'Mounted' };
} else {
return { state: 'inactive', message: 'Not mounted' };
}
}
let output = safe.child_process.execSync(`systemctl show -p ActiveState $(systemd-escape -p --suffix=mount ${hostPath})`, { encoding: 'utf8' }); // --value does not work in ubuntu 16
const state = output ? output.trim().split('=')[1] : '';
let message;
if (state !== 'active') { // find why it failed
output = safe.child_process.execSync(`journalctl -u $(systemd-escape -p --suffix=mount ${hostPath}) -n 10 --no-pager -o cat`, { encoding: 'utf8' });
if (output) {
const rlines = output.split('\n').reverse();
const idx = rlines.findIndex(l => /^mount./.test(l) || l.includes('failed') || l.includes('error') || l.includes('reset'));
if (idx !== -1) message = rlines[idx];
}
if (!message) message = `Could not determine failure reason. ${safe.error ? safe.error.message : ''}`;
} else {
message = 'Mounted';
}
return { state, message };
}
async function tryAddMount(volume, options) {
assert.strictEqual(typeof volume, 'object');
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;
if (volume.mountType === 'sshfs') {
const keyFilePath = path.join(paths.SSHFS_KEYS_DIR, `id_rsa_${volume.mountOptions.host}`);
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);
}
const [error] = await safe(shell.promises.sudo('addMount', [ ADD_MOUNT_CMD, renderMountFile(volume), 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
const status = await getStatus(volume.mountType, volume.hostPath);
if (status.state !== 'active') { // cleanup
await removeMount(volume);
throw new BoxError(BoxError.MOUNT_ERROR, `Failed to mount (${status.state}): ${status.message}`);
}
}