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 ,
2021-05-12 18:00:43 -07:00
list ,
2021-10-11 15:51:16 +02:00
remount ,
2021-05-13 15:33:16 -07:00
getStatus ,
2021-09-28 11:51:01 -07:00
removePrivateFields ,
2025-01-02 11:46:11 +01:00
mountAll ,
// exported for testing
_validateHostPath : validateHostPath
2020-10-27 22:39:05 -07:00
} ;
2025-08-14 11:17:38 +05:30
const assert = require ( 'node:assert' ) ,
2020-10-27 22:39:05 -07:00
BoxError = require ( './boxerror.js' ) ,
2021-05-17 16:23:24 -07:00
constants = require ( './constants.js' ) ,
2025-08-14 11:17:38 +05:30
crypto = require ( 'node:crypto' ) ,
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' ) ,
2025-08-14 11:17:38 +05:30
path = require ( 'node:path' ) ,
2021-06-24 16:59:47 -07:00
paths = require ( './paths.js' ) ,
2020-12-03 23:13:20 -08:00
safe = require ( 'safetydance' ) ,
2025-07-28 12:53:27 +02:00
services = require ( './services.js' ) ;
2021-05-11 17:50:48 -07:00
2021-05-12 18:00:43 -07:00
const VOLUMES _FIELDS = [ 'id' , 'name' , 'hostPath' , 'creationTime' , 'mountType' , 'mountOptionsJson' ] . join ( ',' ) ;
2020-10-27 22:39:05 -07:00
2021-05-12 18:00:43 -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' ) ;
2025-01-02 11:17:24 +01:00
// 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 ;
}
2021-05-12 18:00:43 -07:00
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 ;
2021-05-12 18:00:43 -07:00
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
2025-07-28 12:53:27 +02:00
const id = crypto . randomUUID ( ) . replace ( /-/g , '' ) ; // to make systemd mount file names more readable
2021-06-24 16:59:47 -07:00
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 ) ;
2025-08-01 15:48:09 +02:00
const mount = { description : name , hostPath , mountType , mountOptions } ;
2023-02-25 23:14:54 +01:00
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' ) ;
2025-07-14 16:14:57 +02:00
if ( error && error . code === 'ER_DUP_ENTRY' && error . sqlMessage . indexOf ( 'hostPath' ) !== - 1 ) throw new BoxError ( BoxError . ALREADY _EXISTS , 'directory or mountpoint already in use' ) ;
2021-09-01 15:29:35 -07:00
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 } ) ;
2021-06-24 23:01:18 -07:00
// in theory, we only need to do this mountpoint volumes. but for some reason a restart is required to detect new "mounts"
2021-09-24 10:22:45 -07:00
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' ) ;
2025-07-02 18:25:14 +02: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
2025-08-18 14:19:55 +02:00
await mounts . remount ( volume . hostPath ) ;
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 ) ;
2025-08-01 15:48:09 +02:00
const mount = { description : name , hostPath , mountType , mountOptions } ;
2023-09-20 16:27:39 +02:00
2023-09-29 06:49:55 +05:30
// 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
2025-08-04 14:05:57 +02:00
await mounts . removeMount ( testMount ) ;
2023-09-20 15:48:11 +02:00
2023-09-29 06:49:55 +05:30
// 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
2021-05-12 18:00:43 -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 ( ) {
2021-05-12 18:00:43 -07:00
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
2025-07-02 18:25:14 +02:00
await eventlog . add ( eventlog . ACTION _VOLUME _REMOVE , auditSource , { ... volume } ) ;
2021-06-24 16:19:30 -07:00
2025-01-12 17:41:17 +01:00
if ( volume . mountType === mounts . MOUNT _TYPE _MOUNTPOINT || volume . mountType === mounts . MOUNT _TYPE _FILESYSTEM ) {
2021-09-24 10:22:45 -07:00
safe ( services . rebuildService ( 'sftp' , auditSource ) , { debug } ) ;
2021-06-24 16:19:30 -07:00
} else {
2025-08-04 14:05:57 +02:00
await safe ( mounts . removeMount ( volume ) ) ;
2021-06-24 16:19:30 -07:00
}
2020-10-27 22:39:05 -07:00
}
2021-09-28 11:51:01 -07:00
async function mountAll ( ) {
debug ( 'mountAll: mouting all volumes' ) ;
for ( const volume of await list ( ) ) {
2025-08-01 15:48:09 +02:00
const mount = { description : volume . name , ... volume } ;
await mounts . tryAddMount ( mount , { timeout : 10 , skipCleanup : true } ) ; // have to wait to avoid race with apptask
2021-09-28 11:51:01 -07:00
}
2022-01-26 12:40:28 -08:00
}