Files
cloudron-box/src/volumes.js
T

236 lines
9.4 KiB
JavaScript
Raw Normal View History

2020-10-27 22:39:05 -07:00
'use strict';
exports = module.exports = {
add,
get,
del,
list,
2021-05-13 09:14:50 -07:00
update,
getMountStatus,
2020-10-27 22:39:05 -07:00
};
const assert = require('assert'),
BoxError = require('./boxerror.js'),
2021-01-04 11:05:42 -08:00
collectd = require('./collectd.js'),
2021-05-11 17:50:48 -07:00
database = require('./database.js'),
2020-10-30 11:07:24 -07:00
debug = require('debug')('box:volumes'),
2021-01-04 11:05:42 -08:00
ejs = require('ejs'),
2020-10-27 22:39:05 -07:00
eventlog = require('./eventlog.js'),
2021-01-04 11:05:42 -08:00
fs = require('fs'),
2020-12-03 23:05:06 -08:00
path = require('path'),
2020-12-03 23:13:20 -08:00
safe = require('safetydance'),
2021-01-21 12:53:38 -08:00
services = require('./services.js'),
shell = require('./shell.js'),
2021-05-11 17:50:48 -07:00
uuid = require('uuid');
const VOLUMES_FIELDS = [ 'id', 'name', 'hostPath', 'creationTime', 'mountType', 'mountOptionsJson' ].join(',');
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' });
2020-10-27 22:39:05 -07:00
2021-01-04 11:05:42 -08:00
const COLLECTD_CONFIG_EJS = fs.readFileSync(__dirname + '/collectd/volume.ejs', { encoding: 'utf8' });
const NOOP_CALLBACK = function (error) { if (error) debug(error); };
function postProcess(result) {
assert.strictEqual(typeof result, 'object');
result.mountOptions = safe.JSON.parse(result.mountOptionsJson) || {};
delete result.mountOptionsJson;
return result;
}
2020-10-27 22:39:05 -07:00
function validateName(name) {
assert.strictEqual(typeof name, 'string');
if (!/^[-\w^&'@{}[\],$=!#().%+~ ]+$/.test(name)) return new BoxError(BoxError.BAD_FIELD, 'Invalid name');
return null;
}
// 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':
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 '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');
}
}
function validateHostPath(hostPath, mountType) {
2020-10-27 22:39:05 -07:00
assert.strictEqual(typeof hostPath, 'string');
assert.strictEqual(typeof mountType, 'string');
2020-10-27 22:39:05 -07:00
2020-12-03 23:05:06 -08:00
if (path.normalize(hostPath) !== hostPath) return new BoxError(BoxError.BAD_FIELD, 'hostPath must contain a normalized path', { field: 'hostPath' });
if (!path.isAbsolute(hostPath)) return new BoxError(BoxError.BAD_FIELD, 'backupFolder must be an absolute path', { field: 'hostPath' });
if (hostPath === '/') return new BoxError(BoxError.BAD_FIELD, 'hostPath cannot be /', { field: 'hostPath' });
if (!hostPath.endsWith('/')) hostPath = hostPath + '/'; // ensure trailing slash for the prefix matching to work
const allowedPaths = [ '/mnt/', '/media/', '/srv/', '/opt/' ];
if (!allowedPaths.some(p => hostPath.startsWith(p))) return new BoxError(BoxError.BAD_FIELD, 'hostPath must be under /mnt, /media, /opt or /srv', { field: 'hostPath' });
2020-10-27 22:39:05 -07:00
if (mountType === 'noop') { // we expect user to have already mounted this
const stat = safe.fs.lstatSync(hostPath);
if (!stat) return new BoxError(BoxError.BAD_FIELD, 'hostPath does not exist. Please create it on the server first', { field: 'hostPath' });
if (!stat.isDirectory()) return new BoxError(BoxError.BAD_FIELD, 'hostPath is not a directory', { field: 'hostPath' });
}
2020-12-03 23:13:20 -08:00
2020-10-27 22:39:05 -07:00
return null;
}
async function writeMountFile(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}:${mountOptions.remoteDir}`;
options = `username=${mountOptions.username},password=${mountOptions.password},rw`; // uid=1000 ?
break;
case 'nfs':
type = 'nfs';
what = `${mountOptions.host}:${mountOptions.remoteDir}`;
options = 'noauto,x-systemd.automount'; // _netdev is implicit
break;
case 'ext4':
type = 'ext4';
what = mountOptions.diskPath; // like /dev/disk/by-uuid/uuid
options = 'defaults';
break;
case 'sshfs':
// type = 'sshfs';
// What={{ USER }}@{{ HOST }}:{{ REMOTE DIR }}
// Options=_netdev,allow_other,IdentityFile=/home/{{ MY LOCAL USER WITH SSH KEY IN ITS HOME DIRECTORY }}/.ssh/id_rsa,reconnect,x-systemd.automount,uid=1000,gid=1000
}
const mountFileContents = ejs.render(SYSTEMD_MOUNT_EJS, { name, what, where: hostPath, options, type });
const [error] = await safe(shell.promises.sudo('generateMountFile', [ ADD_MOUNT_CMD, mountFileContents ], {}));
if (error) throw error;
}
async function removeMountFile(volume) {
assert.strictEqual(typeof volume, 'object');
await safe(shell.promises.sudo('generateMountFile', [ RM_MOUNT_CMD, volume.hostPath ], {})); // ignore any error
}
2021-05-13 09:14:50 -07:00
async function update(volume, mountType, mountOptions) {
assert.strictEqual(typeof volume, 'object');
assert.strictEqual(typeof mountType, 'string');
assert.strictEqual(typeof mountOptions, 'object');
let error = validateMountOptions(mountType, mountOptions);
if (error) throw error;
2021-05-13 09:14:50 -07:00
if (mountType === 'noop') {
await safe(removeMountFile(Object.assign({}, volume, { mountType, mountOptions })));
} else {
await safe(writeMountFile(Object.assign({}, volume, { mountType, mountOptions })));
}
let result;
2021-05-13 09:14:50 -07:00
[error, result] = await safe(database.query('UPDATE volumes SET mountType=?, mountOptionsJson=? WHERE id=?', [ mountType, JSON.stringify(mountOptions), volume.id ]));
if (error) throw error;
if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Volume not found');
}
async function getMountStatus(volume) {
assert.strictEqual(typeof volume, 'object');
let [error, activeState] = await safe(shell.promises.exec('getMountStatus', `systemctl is-active $(systemd-escape -p ${volume.hostPath})`));
activeState = activeState || 'no status';
return { activeState };
}
async function add(volume, auditSource) {
assert.strictEqual(typeof volume, 'object');
2020-10-27 22:39:05 -07:00
assert.strictEqual(typeof auditSource, 'object');
const {name, hostPath, mountType, mountOptions} = volume;
2020-10-27 22:39:05 -07:00
let error = validateName(name);
2021-05-11 17:50:48 -07:00
if (error) throw error;
2020-10-27 22:39:05 -07:00
error = validateHostPath(hostPath, mountType);
if (error) throw error;
error = validateMountOptions(mountType, mountOptions);
2021-05-11 17:50:48 -07:00
if (error) throw error;
2020-10-27 22:39:05 -07:00
2021-02-17 23:14:47 -08:00
const id = uuid.v4();
2020-10-27 22:39:05 -07:00
2021-05-11 17:50:48 -07:00
try {
if (mountType !== 'noop') await writeMountFile(volume);
await database.query('INSERT INTO volumes (id, name, hostPath, mountType, mountOptionsJson) VALUES (?, ?, ?, ?, ?)', [ id, name, hostPath, mountType, JSON.stringify(mountOptions) ]);
2021-05-11 17:50:48 -07:00
} catch (error) {
if (error.code === 'ER_DUP_ENTRY' && error.sqlMessage.indexOf('name') !== -1) throw new BoxError(BoxError.ALREADY_EXISTS, 'name already exists');
if (error.code === 'ER_DUP_ENTRY' && error.sqlMessage.indexOf('hostPath') !== -1) throw new BoxError(BoxError.ALREADY_EXISTS, 'hostPath already exists');
if (error.code === 'ER_DUP_ENTRY' && error.sqlMessage.indexOf('PRIMARY') !== -1) throw new BoxError(BoxError.ALREADY_EXISTS, 'id already exists');
throw error;
}
2020-10-27 22:39:05 -07:00
2021-05-11 17:50:48 -07:00
eventlog.add(eventlog.ACTION_VOLUME_ADD, auditSource, { id, name, hostPath });
services.rebuildService('sftp', NOOP_CALLBACK);
2021-01-04 11:05:42 -08:00
2021-05-11 17:50:48 -07:00
const collectdConf = ejs.render(COLLECTD_CONFIG_EJS, { volumeId: id, hostPath });
collectd.addProfile(id, collectdConf, NOOP_CALLBACK);
2020-10-27 22:39:05 -07:00
2021-05-11 17:50:48 -07:00
return id;
2020-10-27 22:39:05 -07:00
}
2021-05-11 17:50:48 -07:00
async function get(id) {
2020-10-27 22:39:05 -07:00
assert.strictEqual(typeof id, 'string');
2021-05-11 17:50:48 -07:00
const result = await database.query(`SELECT ${VOLUMES_FIELDS} FROM volumes WHERE id=?`, [ id ]);
if (result.length === 0) return null;
2020-10-27 22:39:05 -07:00
return postProcess(result[0]);
2020-10-27 22:39:05 -07:00
}
2021-05-11 17:50:48 -07:00
async function list() {
const results = await database.query(`SELECT ${VOLUMES_FIELDS} FROM volumes ORDER BY name`);
results.forEach(postProcess);
return results;
2020-10-27 22:39:05 -07:00
}
2021-05-11 17:50:48 -07:00
async function del(volume, auditSource) {
2020-10-28 15:51:43 -07:00
assert.strictEqual(typeof volume, 'object');
2020-10-27 22:39:05 -07:00
assert.strictEqual(typeof auditSource, 'object');
2021-05-11 17:50:48 -07:00
try {
const result = await database.query('DELETE FROM volumes WHERE id=?', [ volume.id ]);
if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Volume not found');
} catch (error) {
if (error.code === 'ER_ROW_IS_REFERENCED_2') throw new BoxError(BoxError.CONFLICT, 'Volume is in use');
throw error;
}
eventlog.add(eventlog.ACTION_VOLUME_REMOVE, auditSource, { volume });
services.rebuildService('sftp', async function () {
await safe(removeMountFile(volume));
});
2021-05-11 17:50:48 -07:00
collectd.removeProfile(volume.id, NOOP_CALLBACK);
2020-10-27 22:39:05 -07:00
}