Files
cloudron-box/src/mounts.js
T
Girish Ramakrishnan 6ace8d1ac5 volumes: fix various mount related issues
Various notes on mounting:

* The permissions come from the mounted file system and not the mount point.
This means that if we change the perms before mounting, it is overridden by
whatever is in the actual file system.

* uid/gid only works for permission-less file systems

SFTP container notes:

* Assumes that nothing changed if the host path hasn't changed. This means that
if a user changes the disk uuid, reload doesn't work.

* Not sure how/why, but even after unmounting the container can still access the old
mount files (!). With ext4 on disk change or nfs after root path change, the file manager
continues to be able to access the old mounts (despite umount succeeding).

All this led to following changes:

* Remove editing of volumes. Just allow editing username/password.
* edit UI then just also provides a way to re-mount.
* Change mode of mountpoint to be 777 post mounting for ease of use. Otherwise, we have to
make the user do this by ssh. this can always become options later.
2021-06-21 16:11:48 -07:00

170 lines
7.4 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'),
promiseRetry = require('./promise-retry.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
if (safe.child_process.execSync(`mountpoint -q -- ${hostPath}`, { encoding: 'utf8' })) {
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);
}
await safe(shell.promises.sudo('addMount', [ ADD_MOUNT_CMD, renderMountFile(volume), options.timeout ], {}));
const status = await getStatus(volume.mountType, volume.hostPath);
if (status.state !== 'active') { // cleanup
await removeMount(volume);
throw new BoxError(BoxError.MOUNT_ERROR, `Mount is not active (${status.state}): ${status.message}`);
}
}