'use strict'; exports = module.exports = { add, get, update, del, list, remount, getStatus, removePrivateFields, mountAll, // exported for testing _validateHostPath: validateHostPath }; const assert = require('node:assert'), BoxError = require('./boxerror.js'), constants = require('./constants.js'), crypto = require('node:crypto'), database = require('./database.js'), debug = require('debug')('box:volumes'), eventlog = require('./eventlog.js'), mounts = require('./mounts.js'), path = require('node:path'), paths = require('./paths.js'), safe = require('safetydance'), services = require('./services.js'); 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) { assert.strictEqual(typeof hostPath, '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'); // 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'); if (hostPath === '/') return new BoxError(BoxError.BAD_FIELD, 'hostPath cannot be /'); 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'); const reservedPaths = [ `${paths.VOLUMES_MOUNT_DIR}` ]; if (reservedPaths.some(p => hostPath === p || hostPath.startsWith(p + '/'))) return new BoxError(BoxError.BAD_FIELD, 'hostPath is reserved'); 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 = crypto.randomUUID().replace(/-/g, ''); // to make systemd mount file names more readable let hostPath; if (mountType === mounts.MOUNT_TYPE_MOUNTPOINT || mountType === mounts.MOUNT_TYPE_FILESYSTEM) { error = validateHostPath(mountOptions.hostPath); if (error) throw error; hostPath = mountOptions.hostPath; } else { hostPath = path.join(paths.VOLUMES_MOUNT_DIR, id); const mount = { description: 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, 'directory or mountpoint already in use'); 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.hostPath); } 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 === mounts.MOUNT_TYPE_SSHFS) { newVolume.mountOptions.privateKey = constants.SECRET_PLACEHOLDER; } else if (newVolume.mountType === mounts.MOUNT_TYPE_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 { name, mountType } = volume; 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'); const error = mounts.validateMountOptions(mountType, mountOptions); if (error) throw error; // put old secret back in place if no new secret is provided if (mountType === mounts.MOUNT_TYPE_SSHFS) { if (mountOptions.privateKey === constants.SECRET_PLACEHOLDER) mountOptions.privateKey = volume.mountOptions.privateKey; } else if (mountType === mounts.MOUNT_TYPE_CIFS) { if (mountOptions.password === constants.SECRET_PLACEHOLDER) mountOptions.password = volume.mountOptions.password; } const hostPath = path.join(paths.VOLUMES_MOUNT_DIR, id); const mount = { description: name, hostPath, mountType, mountOptions }; // first try to mount at /mnt/volumes/-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 === mounts.MOUNT_TYPE_MOUNTPOINT || volume.mountType === mounts.MOUNT_TYPE_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()) { const mount = { description: volume.name, ...volume }; await mounts.tryAddMount(mount, { timeout: 10, skipCleanup: true }); // have to wait to avoid race with apptask } }