Files
cloudron-box/src/volumes.js
T

218 lines
8.8 KiB
JavaScript
Raw Normal View History

2020-10-27 22:39:05 -07:00
'use strict';
exports = module.exports = {
add,
get,
2023-09-20 15:48:11 +02:00
update,
2020-10-27 22:39:05 -07:00
del,
list,
2021-10-11 15:51:16 +02:00
remount,
2021-05-13 15:33:16 -07:00
getStatus,
removePrivateFields,
2025-01-02 11:46:11 +01:00
mountAll,
// exported for testing
_validateHostPath: validateHostPath
2020-10-27 22:39:05 -07:00
};
const assert = require('assert'),
BoxError = require('./boxerror.js'),
2021-05-17 16:23:24 -07:00
constants = require('./constants.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'),
2020-10-27 22:39:05 -07:00
eventlog = require('./eventlog.js'),
2021-05-14 15:07:29 -07:00
mounts = require('./mounts.js'),
2020-12-03 23:05:06 -08:00
path = require('path'),
2021-06-24 16:59:47 -07:00
paths = require('./paths.js'),
2020-12-03 23:13:20 -08:00
safe = require('safetydance'),
2021-01-21 12:53:38 -08:00
services = require('./services.js'),
2021-05-11 17:50:48 -07:00
uuid = require('uuid');
const VOLUMES_FIELDS = [ 'id', 'name', 'hostPath', 'creationTime', 'mountType', 'mountOptionsJson' ].join(',');
2020-10-27 22:39:05 -07:00
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;
}
2025-01-02 11:46:11 +01:00
function validateHostPath(hostPath) {
2020-10-27 22:39:05 -07:00
assert.strictEqual(typeof hostPath, 'string');
2022-02-07 13:19:59 -08:00
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');
// otherwise, we could follow some symlink to mount paths outside the allowed paths
if (safe.fs.realpathSync(hostPath) !== hostPath) return new BoxError(BoxError.BAD_FIELD, 'hostPath must be a realpath without symlinks');
2020-12-03 23:05:06 -08:00
2022-02-07 13:19:59 -08:00
if (hostPath === '/') return new BoxError(BoxError.BAD_FIELD, 'hostPath cannot be /');
2020-12-03 23:05:06 -08:00
2025-01-02 11:22:09 +01:00
const allowedPaths = [ '/mnt', '/media', '/srv', '/opt' ];
2025-01-02 11:46:11 +01:00
if (!allowedPaths.some(p => hostPath.startsWith(p + '/'))) return new BoxError(BoxError.BAD_FIELD, 'hostPath must be under /mnt, /media, /opt or /srv');
2020-12-03 23:05:06 -08:00
2025-01-02 11:22:09 +01:00
const reservedPaths = [ `${paths.VOLUMES_MOUNT_DIR}` ];
if (reservedPaths.some(p => hostPath === p || hostPath.startsWith(p + '/'))) return new BoxError(BoxError.BAD_FIELD, 'hostPath is reserved');
2024-08-08 14:45:50 +02:00
2025-01-02 11:46:11 +01:00
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');
2020-12-03 23:13:20 -08:00
2020-10-27 22:39:05 -07:00
return null;
}
async function add(volume, auditSource) {
assert.strictEqual(typeof volume, 'object');
2020-10-27 22:39:05 -07:00
assert.strictEqual(typeof auditSource, 'object');
2021-06-24 16:59:47 -07:00
const {name, 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
2021-05-14 15:07:29 -07:00
error = mounts.validateMountOptions(mountType, mountOptions);
2021-05-11 17:50:48 -07:00
if (error) throw error;
2020-10-27 22:39:05 -07:00
2021-06-24 16:59:47 -07:00
const id = uuid.v4().replace(/-/g, ''); // to make systemd mount file names more readable
2023-02-25 23:14:54 +01:00
let hostPath;
2025-01-12 17:41:17 +01:00
if (mountType === mounts.MOUNT_TYPE_MOUNTPOINT || mountType === mounts.MOUNT_TYPE_FILESYSTEM) {
2025-01-02 11:46:11 +01:00
error = validateHostPath(mountOptions.hostPath);
2021-06-24 16:59:47 -07:00
if (error) throw error;
2023-02-25 23:14:54 +01:00
hostPath = mountOptions.hostPath;
2021-06-24 16:59:47 -07:00
} else {
2023-02-25 23:14:54 +01:00
hostPath = path.join(paths.VOLUMES_MOUNT_DIR, id);
const mount = { name, hostPath, mountType, mountOptions };
await mounts.tryAddMount(mount, { timeout: 10 }); // 10 seconds
2021-06-24 16:59:47 -07:00
}
2020-10-27 22:39:05 -07:00
2023-02-25 23:14:54 +01:00
[error] = await safe(database.query('INSERT INTO volumes (id, name, hostPath, mountType, mountOptionsJson) VALUES (?, ?, ?, ?, ?)', [ id, name, hostPath, mountType, JSON.stringify(mountOptions) ]));
2021-09-01 15:29:35 -07:00
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;
2020-10-27 22:39:05 -07:00
2023-02-25 23:14:54 +01:00
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 });
2021-01-04 11:05:42 -08:00
2021-05-11 17:50:48 -07:00
return id;
2020-10-27 22:39:05 -07:00
}
2021-10-11 15:51:16 +02:00
async function remount(volume, auditSource) {
assert.strictEqual(typeof volume, 'object');
assert.strictEqual(typeof auditSource, 'object');
2022-02-24 20:04:46 -08:00
await eventlog.add(eventlog.ACTION_VOLUME_REMOUNT, auditSource, { volume });
2021-10-11 15:51:16 +02:00
2022-01-26 12:40:28 -08:00
if (!mounts.isManagedProvider(volume.mountType)) throw new BoxError(BoxError.NOT_SUPPORTED, 'Volume does not support remount');
2021-10-11 15:51:16 +02:00
2021-10-11 10:29:46 -07:00
await mounts.remount(volume);
2021-10-11 15:51:16 +02:00
}
2021-05-19 09:50:50 -07:00
async function getStatus(volume) {
assert.strictEqual(typeof volume, 'object');
return await mounts.getStatus(volume.mountType, volume.hostPath); // { state, message }
}
2021-06-21 16:35:08 -07:00
function removePrivateFields(volume) {
const newVolume = Object.assign({}, volume);
2025-01-12 17:41:17 +01:00
if (newVolume.mountType === mounts.MOUNT_TYPE_SSHFS) {
2021-06-21 16:35:08 -07:00
newVolume.mountOptions.privateKey = constants.SECRET_PLACEHOLDER;
2025-01-12 17:41:17 +01:00
} else if (newVolume.mountType === mounts.MOUNT_TYPE_CIFS) {
2021-06-21 16:35:08 -07:00
newVolume.mountOptions.password = constants.SECRET_PLACEHOLDER;
}
return newVolume;
}
2023-09-20 16:27:39 +02:00
// only network mounts can be updated here through mountOptions to update logon information
2023-09-20 15:48:11 +02:00
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);
2023-10-09 10:26:24 +05:30
const { name, mountType } = volume;
2023-09-20 15:48:11 +02:00
2025-01-12 17:41:17 +01:00
if (mountType !== mounts.MOUNT_TYPE_CIFS && mountType !== mounts.MOUNT_TYPE_SSHFS && mountType !== mounts.MOUNT_TYPE_NFS) throw new BoxError(BoxError.BAD_FIELD, 'Only network mounts can be updated');
2023-09-20 16:27:39 +02:00
const error = mounts.validateMountOptions(mountType, mountOptions);
2023-09-20 15:48:11 +02:00
if (error) throw error;
2023-09-20 16:27:39 +02:00
// put old secret back in place if no new secret is provided
2025-01-12 17:41:17 +01:00
if (mountType === mounts.MOUNT_TYPE_SSHFS) {
2023-09-20 16:27:39 +02:00
if (mountOptions.privateKey === constants.SECRET_PLACEHOLDER) mountOptions.privateKey = volume.mountOptions.privateKey;
2025-01-12 17:41:17 +01:00
} else if (mountType === mounts.MOUNT_TYPE_CIFS) {
2023-09-20 16:27:39 +02:00
if (mountOptions.password === constants.SECRET_PLACEHOLDER) mountOptions.password = volume.mountOptions.password;
2023-09-20 15:48:11 +02:00
}
2023-09-20 16:27:39 +02:00
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);
2023-09-20 15:48:11 +02:00
// 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 ]);
2023-09-20 15:48:11 +02:00
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 });
}
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-08-19 13:24:38 -07:00
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');
2021-05-11 17:50:48 -07:00
2022-02-24 20:04:46 -08:00
await eventlog.add(eventlog.ACTION_VOLUME_REMOVE, auditSource, { volume });
2025-01-12 17:41:17 +01:00
if (volume.mountType === mounts.MOUNT_TYPE_MOUNTPOINT || volume.mountType === mounts.MOUNT_TYPE_FILESYSTEM) {
safe(services.rebuildService('sftp', auditSource), { debug });
} else {
await safe(mounts.removeMount(volume));
}
2020-10-27 22:39:05 -07:00
}
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
}
2022-01-26 12:40:28 -08:00
}