64381e2a04
this also moves out the attempt validation logic from mounts code into volumes. mounts.tryAddMount is also used in backup code
216 lines
8.4 KiB
JavaScript
216 lines
8.4 KiB
JavaScript
'use strict';
|
|
|
|
exports = module.exports = {
|
|
add,
|
|
get,
|
|
update,
|
|
del,
|
|
list,
|
|
remount,
|
|
getStatus,
|
|
removePrivateFields,
|
|
|
|
mountAll
|
|
};
|
|
|
|
const assert = require('assert'),
|
|
BoxError = require('./boxerror.js'),
|
|
constants = require('./constants.js'),
|
|
database = require('./database.js'),
|
|
debug = require('debug')('box:volumes'),
|
|
eventlog = require('./eventlog.js'),
|
|
mounts = require('./mounts.js'),
|
|
path = require('path'),
|
|
paths = require('./paths.js'),
|
|
safe = require('safetydance'),
|
|
services = require('./services.js'),
|
|
uuid = require('uuid');
|
|
|
|
const VOLUMES_FIELDS = [ 'id', 'name', 'hostPath', 'creationTime', 'mountType', 'mountOptionsJson' ].join(',');
|
|
|
|
function postProcess(result) {
|
|
assert.strictEqual(typeof result, 'object');
|
|
|
|
result.mountOptions = safe.JSON.parse(result.mountOptionsJson) || {};
|
|
delete result.mountOptionsJson;
|
|
|
|
return result;
|
|
}
|
|
|
|
function validateName(name) {
|
|
assert.strictEqual(typeof name, 'string');
|
|
|
|
if (!/^[-\w^&'@{}[\],$=!#().%+~ ]+$/.test(name)) return new BoxError(BoxError.BAD_FIELD, 'Invalid name');
|
|
|
|
return null;
|
|
}
|
|
|
|
function validateHostPath(hostPath, mountType) {
|
|
assert.strictEqual(typeof hostPath, 'string');
|
|
assert.strictEqual(typeof mountType, 'string');
|
|
|
|
if (path.normalize(hostPath) !== hostPath) return new BoxError(BoxError.BAD_FIELD, 'hostPath must contain a normalized path');
|
|
if (!path.isAbsolute(hostPath)) return new BoxError(BoxError.BAD_FIELD, 'hostPath must be an absolute path');
|
|
|
|
if (hostPath === '/') return new BoxError(BoxError.BAD_FIELD, 'hostPath cannot be /');
|
|
|
|
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');
|
|
|
|
if (!constants.TEST) { // 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');
|
|
if (!stat.isDirectory()) return new BoxError(BoxError.BAD_FIELD, 'hostPath is not a directory');
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function add(volume, auditSource) {
|
|
assert.strictEqual(typeof volume, 'object');
|
|
assert.strictEqual(typeof auditSource, 'object');
|
|
|
|
const {name, mountType, mountOptions} = volume;
|
|
|
|
let error = validateName(name);
|
|
if (error) throw error;
|
|
|
|
error = mounts.validateMountOptions(mountType, mountOptions);
|
|
if (error) throw error;
|
|
|
|
const id = uuid.v4().replace(/-/g, ''); // to make systemd mount file names more readable
|
|
|
|
let hostPath;
|
|
if (mountType === 'mountpoint' || mountType === 'filesystem') {
|
|
error = validateHostPath(mountOptions.hostPath, mountType);
|
|
if (error) throw error;
|
|
hostPath = mountOptions.hostPath;
|
|
} else {
|
|
hostPath = path.join(paths.VOLUMES_MOUNT_DIR, id);
|
|
const mount = { name, hostPath, mountType, mountOptions };
|
|
await mounts.tryAddMount(mount, { timeout: 10 }); // 10 seconds
|
|
}
|
|
|
|
[error] = await safe(database.query('INSERT INTO volumes (id, name, hostPath, mountType, mountOptionsJson) VALUES (?, ?, ?, ?, ?)', [ id, name, hostPath, mountType, JSON.stringify(mountOptions) ]));
|
|
if (error && error.code === 'ER_DUP_ENTRY' && error.sqlMessage.indexOf('name') !== -1) throw new BoxError(BoxError.ALREADY_EXISTS, 'name already exists');
|
|
if (error && error.code === 'ER_DUP_ENTRY' && error.sqlMessage.indexOf('hostPath') !== -1) throw new BoxError(BoxError.ALREADY_EXISTS, 'hostPath already exists');
|
|
if (error && error.code === 'ER_DUP_ENTRY' && error.sqlMessage.indexOf('PRIMARY') !== -1) throw new BoxError(BoxError.ALREADY_EXISTS, 'id already exists');
|
|
if (error) throw error;
|
|
|
|
await eventlog.add(eventlog.ACTION_VOLUME_ADD, auditSource, { id, name, hostPath });
|
|
// in theory, we only need to do this mountpoint volumes. but for some reason a restart is required to detect new "mounts"
|
|
safe(services.rebuildService('sftp', auditSource), { debug });
|
|
|
|
return id;
|
|
}
|
|
|
|
async function remount(volume, auditSource) {
|
|
assert.strictEqual(typeof volume, 'object');
|
|
assert.strictEqual(typeof auditSource, 'object');
|
|
|
|
await eventlog.add(eventlog.ACTION_VOLUME_REMOUNT, auditSource, { volume });
|
|
|
|
if (!mounts.isManagedProvider(volume.mountType)) throw new BoxError(BoxError.NOT_SUPPORTED, 'Volume does not support remount');
|
|
|
|
await mounts.remount(volume);
|
|
}
|
|
|
|
async function getStatus(volume) {
|
|
assert.strictEqual(typeof volume, 'object');
|
|
|
|
return await mounts.getStatus(volume.mountType, volume.hostPath); // { state, message }
|
|
}
|
|
|
|
function removePrivateFields(volume) {
|
|
const newVolume = Object.assign({}, volume);
|
|
|
|
if (newVolume.mountType === 'sshfs') {
|
|
newVolume.mountOptions.privateKey = constants.SECRET_PLACEHOLDER;
|
|
} else if (newVolume.mountType === 'cifs') {
|
|
newVolume.mountOptions.password = constants.SECRET_PLACEHOLDER;
|
|
}
|
|
|
|
return newVolume;
|
|
}
|
|
|
|
// only network mounts can be updated here through mountOptions to update logon information
|
|
async function update(id, mountOptions, auditSource) {
|
|
assert.strictEqual(typeof id, 'string');
|
|
assert.strictEqual(typeof mountOptions, 'object');
|
|
assert.strictEqual(typeof auditSource, 'object');
|
|
|
|
const volume = await get(id);
|
|
const mountType = volume.mountType;
|
|
const name = volume.name;
|
|
|
|
if (mountType !== 'cifs' && mountType !== 'sshfs' && mountType !== 'nfs') return;
|
|
|
|
const error = mounts.validateMountOptions(mountType, mountOptions);
|
|
if (error) throw error;
|
|
|
|
// put old secret back in place if no new secret is provided
|
|
if (mountType === 'sshfs') {
|
|
if (mountOptions.privateKey === constants.SECRET_PLACEHOLDER) mountOptions.privateKey = volume.mountOptions.privateKey;
|
|
} else if (mountType === 'cifs') {
|
|
if (mountOptions.password === constants.SECRET_PLACEHOLDER) mountOptions.password = volume.mountOptions.password;
|
|
}
|
|
|
|
const hostPath = path.join(paths.VOLUMES_MOUNT_DIR, id);
|
|
const mount = { name, hostPath, mountType: mountType, mountOptions };
|
|
|
|
// first try to mount at /mnt/volumes/<volumeId>-validation
|
|
const testMount = Object.assign({}, mount, { hostPath: `${hostPath}-validation` });
|
|
await mounts.tryAddMount(testMount, { timeout: 10 }); // 10 seconds
|
|
await mounts.removeMount(testMount);
|
|
|
|
// update the mount
|
|
await mounts.tryAddMount(mount, { timeout: 10 }); // 10 seconds
|
|
await database.query('UPDATE volumes SET hostPath=?,mountOptionsJson=? WHERE id=?', [ hostPath, JSON.stringify(mountOptions), id ]);
|
|
await eventlog.add(eventlog.ACTION_VOLUME_UPDATE, auditSource, { id, name, hostPath });
|
|
// in theory, we only need to do this mountpoint volumes. but for some reason a restart is required to detect new "mounts"
|
|
safe(services.rebuildService('sftp', auditSource), { debug });
|
|
}
|
|
|
|
async function get(id) {
|
|
assert.strictEqual(typeof id, 'string');
|
|
|
|
const result = await database.query(`SELECT ${VOLUMES_FIELDS} FROM volumes WHERE id=?`, [ id ]);
|
|
if (result.length === 0) return null;
|
|
|
|
return postProcess(result[0]);
|
|
}
|
|
|
|
async function list() {
|
|
const results = await database.query(`SELECT ${VOLUMES_FIELDS} FROM volumes ORDER BY name`);
|
|
results.forEach(postProcess);
|
|
return results;
|
|
}
|
|
|
|
async function del(volume, auditSource) {
|
|
assert.strictEqual(typeof volume, 'object');
|
|
assert.strictEqual(typeof auditSource, 'object');
|
|
|
|
const [error, result] = await safe(database.query('DELETE FROM volumes WHERE id=?', [ volume.id ]));
|
|
if (error && error.code === 'ER_ROW_IS_REFERENCED_2') throw new BoxError(BoxError.CONFLICT, 'Volume is in use');
|
|
if (error) throw error;
|
|
if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Volume not found');
|
|
|
|
await eventlog.add(eventlog.ACTION_VOLUME_REMOVE, auditSource, { volume });
|
|
|
|
if (volume.mountType === 'mountpoint' || volume.mountType === 'filesystem') {
|
|
safe(services.rebuildService('sftp', auditSource), { debug });
|
|
} else {
|
|
await safe(mounts.removeMount(volume));
|
|
}
|
|
}
|
|
|
|
async function mountAll() {
|
|
debug('mountAll: mouting all volumes');
|
|
|
|
for (const volume of await list()) {
|
|
await mounts.tryAddMount(volume, { timeout: 10, skipCleanup: true }); // have to wait to avoid race with apptask
|
|
}
|
|
}
|