6ace8d1ac5
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.
170 lines
7.4 KiB
JavaScript
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}`);
|
|
}
|
|
}
|