2021-05-14 15:07:29 -07:00
'use strict' ;
exports = module . exports = {
2022-01-26 12:40:28 -08:00
isManagedProvider ,
2021-05-21 17:31:54 -07:00
tryAddMount ,
2021-06-21 12:11:05 -07:00
removeMount ,
2021-05-14 15:07:29 -07:00
validateMountOptions ,
2021-05-19 09:50:50 -07:00
getStatus ,
2025-01-12 17:41:17 +01:00
remount ,
2025-01-12 18:25:40 +01:00
MOUNT _TYPE _FILESYSTEM : 'filesystem' ,
2025-01-12 17:41:17 +01:00
MOUNT _TYPE _MOUNTPOINT : 'mountpoint' ,
MOUNT _TYPE _CIFS : 'cifs' ,
MOUNT _TYPE _NFS : 'nfs' ,
MOUNT _TYPE _SSHFS : 'sshfs' ,
2025-09-23 17:30:09 +02:00
MOUNT _TYPE _EXT4 : 'ext4' , // raw disk path
MOUNT _TYPE _XFS : 'xfs' , // raw disk path
MOUNT _TYPE _DISK : 'disk' , // this provides a selector of block devices
2021-05-14 15:07:29 -07:00
} ;
2025-08-14 11:17:38 +05:30
const assert = require ( 'node:assert' ) ,
2021-05-14 15:07:29 -07:00
BoxError = require ( './boxerror.js' ) ,
2021-05-17 16:23:24 -07:00
constants = require ( './constants.js' ) ,
2021-09-29 20:15:54 -07:00
debug = require ( 'debug' ) ( 'box:mounts' ) ,
2021-05-14 15:07:29 -07:00
ejs = require ( 'ejs' ) ,
2025-08-14 11:17:38 +05:30
fs = require ( 'node:fs' ) ,
path = require ( 'node:path' ) ,
2021-05-18 16:49:39 +02:00
paths = require ( './paths.js' ) ,
2021-05-14 15:07:29 -07:00
safe = require ( 'safetydance' ) ,
2024-10-14 19:10:31 +02:00
shell = require ( './shell.js' ) ( 'mounts' ) ;
2021-05-14 15:07:29 -07:00
const ADD _MOUNT _CMD = path . join ( _ _dirname , 'scripts/addmount.sh' ) ;
const RM _MOUNT _CMD = path . join ( _ _dirname , 'scripts/rmmount.sh' ) ;
2021-10-11 15:51:16 +02:00
const REMOUNT _MOUNT _CMD = path . join ( _ _dirname , 'scripts/remountmount.sh' ) ;
2024-02-29 11:51:57 +01:00
const SYSTEMD _MOUNT _EJS = fs . readFileSync ( path . join ( _ _dirname , 'systemd-mount.ejs' ) , { encoding : 'utf8' } ) ;
2021-05-14 15:07:29 -07:00
// https://man7.org/linux/man-pages/man8/mount.8.html
2025-09-23 17:30:09 +02:00
async function validateMountOptions ( type , options ) {
2021-05-14 15:07:29 -07:00
assert . strictEqual ( typeof type , 'string' ) ;
assert . strictEqual ( typeof options , 'object' ) ;
switch ( type ) {
2025-01-12 17:41:17 +01:00
case exports . MOUNT _TYPE _FILESYSTEM :
case exports . MOUNT _TYPE _MOUNTPOINT :
2023-02-25 23:14:54 +01:00
if ( typeof options . hostPath !== 'string' ) return new BoxError ( BoxError . BAD _FIELD , 'hostPath is not a string' ) ;
2021-05-14 15:07:29 -07:00
return null ;
2025-01-12 17:41:17 +01:00
case exports . MOUNT _TYPE _CIFS :
2021-05-14 15:07:29 -07:00
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' ) ;
2022-01-10 14:27:19 +01:00
if ( 'seal' in options && typeof options . seal !== 'boolean' ) return new BoxError ( BoxError . BAD _FIELD , 'seal is not a boolean' ) ;
2021-05-14 15:07:29 -07:00
return null ;
2025-01-12 17:41:17 +01:00
case exports . MOUNT _TYPE _NFS :
2021-05-14 15:07:29 -07:00
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 ;
2025-01-12 17:41:17 +01:00
case exports . MOUNT _TYPE _SSHFS :
2021-05-18 16:49:39 +02:00
if ( typeof options . user !== 'string' ) return new BoxError ( BoxError . BAD _FIELD , 'user is not a string' ) ;
if ( typeof options . privateKey !== 'string' ) return new BoxError ( BoxError . BAD _FIELD , 'privateKey is not a string' ) ;
if ( typeof options . port !== 'number' ) return new BoxError ( BoxError . BAD _FIELD , 'port is not a number' ) ;
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 ;
2025-01-12 17:41:17 +01:00
case exports . MOUNT _TYPE _EXT4 :
case exports . MOUNT _TYPE _XFS :
2025-11-05 18:29:45 +01:00
case exports . MOUNT _TYPE _DISK : {
2021-05-14 15:07:29 -07:00
if ( typeof options . diskPath !== 'string' ) return new BoxError ( BoxError . BAD _FIELD , 'diskPath is not a string' ) ;
2025-09-23 17:30:09 +02:00
const [ error , output ] = await safe ( shell . spawn ( 'lsblk' , [ '--paths' , '--json' , '--list' , '--fs' , options . diskPath ] , { encoding : 'utf8' } ) ) ;
if ( error ) return new BoxError ( BoxError . BAD _FIELD , ` Bad disk path: ${ error . message } ` ) ;
const info = safe . JSON . parse ( output ) ;
if ( ! info ) return new BoxError ( BoxError . BAD _FIELD , ` Bad disk path: ${ safe . error . message } ` ) ;
for ( const mountpoint of info . blockdevices [ 0 ] . mountpoints ) {
if ( mountpoint === null ) break ; // [ null ] means not mounted anywhere
if ( mountpoint === '/' || mountpoint . startsWith ( '/home' ) || mountpoint . startsWith ( '/boot' ) ) return new BoxError ( BoxError . BAD _FIELD , 'Disk is mounted in a protected location' ) ;
}
2021-05-14 15:07:29 -07:00
return null ;
2025-09-23 17:30:09 +02:00
}
2021-05-14 15:07:29 -07:00
default :
2021-09-28 11:43:15 -07:00
return new BoxError ( BoxError . BAD _FIELD , 'Bad mount type' ) ;
2021-05-14 15:07:29 -07:00
}
}
2024-08-08 14:12:06 +02:00
// managed providers are those for which we setup systemd mount file under /mnt/volumes
2022-01-26 12:40:28 -08:00
function isManagedProvider ( provider ) {
2025-01-12 17:41:17 +01:00
switch ( provider ) {
case exports . MOUNT _TYPE _SSHFS :
case exports . MOUNT _TYPE _CIFS :
case exports . MOUNT _TYPE _NFS :
case exports . MOUNT _TYPE _EXT4 :
case exports . MOUNT _TYPE _XFS :
case exports . MOUNT _TYPE _DISK :
return true ;
default :
return false ;
}
2021-07-14 11:07:19 -07:00
}
2021-06-21 12:11:05 -07:00
// https://www.man7.org/linux/man-pages/man8/mount.8.html for various mount option flags
// nfs - no_root_squash is mode on server to map all root to 'nobody' user. all_squash does this for all users (making it like ftp)
// sshfs - supports users/permissions
// cifs - does not support permissions
2024-02-20 23:09:49 +01:00
async function renderMountFile ( mount ) {
2021-09-28 11:43:15 -07:00
assert . strictEqual ( typeof mount , 'object' ) ;
2021-05-14 15:07:29 -07:00
2025-08-01 15:48:09 +02:00
const { description , hostPath , mountType , mountOptions } = mount ;
2021-05-14 15:07:29 -07:00
2025-01-12 18:02:06 +01:00
let options , what , type , dependsOn ;
2021-05-14 15:07:29 -07:00
switch ( mountType ) {
2025-01-12 17:41:17 +01:00
case exports . MOUNT _TYPE _CIFS : {
2024-10-16 10:25:07 +02:00
const out = await shell . spawn ( 'systemd-escape' , [ '-p' , hostPath ] , { encoding : 'utf8' } ) ; // this ensures uniqueness of creds file
2022-03-29 20:57:09 -07:00
const credentialsFilePath = path . join ( paths . CIFS _CREDENTIALS _DIR , ` ${ out . trim ( ) } .cred ` ) ;
if ( ! safe . fs . writeFileSync ( credentialsFilePath , ` username= ${ mountOptions . username } \n password= ${ mountOptions . password } \n ` , { mode : 0o600 } ) ) throw new BoxError ( BoxError . FS _ERROR , ` Could not write credentials file: ${ safe . error . message } ` ) ;
2021-05-14 15:07:29 -07:00
type = 'cifs' ;
what = ` // ${ mountOptions . host } ` + path . join ( '/' , mountOptions . remoteDir ) ;
2022-03-29 20:57:09 -07:00
options = ` credentials= ${ credentialsFilePath } ,rw, ${ mountOptions . seal ? 'seal,' : '' } iocharset=utf8,file_mode=0666,dir_mode=0777,uid=yellowtent,gid=yellowtent ` ;
2025-01-12 18:02:06 +01:00
dependsOn = 'network-online.target' ;
2021-05-14 15:07:29 -07:00
break ;
2022-03-29 20:57:09 -07:00
}
2025-01-12 17:41:17 +01:00
case exports . MOUNT _TYPE _NFS :
2021-05-14 15:07:29 -07:00
type = 'nfs' ;
what = ` ${ mountOptions . host } : ${ mountOptions . remoteDir } ` ;
2021-06-18 23:48:39 -07:00
options = 'noauto' ; // noauto means it is not a blocker for local-fs.target. _netdev is implicit. rw,hard,tcp,rsize=8192,wsize=8192,timeo=14
2025-01-12 18:02:06 +01:00
dependsOn = 'network-online.target' ;
2021-05-14 15:07:29 -07:00
break ;
2025-01-12 17:41:17 +01:00
case exports . MOUNT _TYPE _EXT4 :
2021-05-14 15:07:29 -07:00
type = 'ext4' ;
what = mountOptions . diskPath ; // like /dev/disk/by-uuid/uuid or /dev/disk/by-id/scsi-id
options = 'discard,defaults,noatime' ;
break ;
2025-01-12 17:41:17 +01:00
case exports . MOUNT _TYPE _XFS :
2022-06-08 10:32:25 -07:00
type = 'xfs' ;
what = mountOptions . diskPath ; // like /dev/disk/by-uuid/uuid or /dev/disk/by-id/scsi-id
2025-01-12 17:33:19 +01:00
options = 'discard,defaults,noatime,pquota' ;
2023-08-08 13:21:56 +02:00
break ;
2025-01-12 17:41:17 +01:00
case exports . MOUNT _TYPE _DISK :
2023-08-08 13:21:56 +02:00
type = 'auto' ;
what = mountOptions . diskPath ; // like /dev/disk/by-uuid/uuid or /dev/disk/by-id/scsi-id
2022-06-08 10:32:25 -07:00
options = 'discard,defaults,noatime' ;
break ;
2025-01-12 17:41:17 +01:00
case exports . MOUNT _TYPE _SSHFS : {
2025-11-06 16:25:04 +01:00
const keyFilePath = path . join ( paths . SSHFS _KEYS _DIR , ` identity_file_ ${ path . basename ( hostPath ) } ` ) ;
2022-03-29 20:15:55 -07:00
if ( ! safe . fs . writeFileSync ( keyFilePath , ` ${ mount . mountOptions . privateKey } \n ` , { mode : 0o600 } ) ) throw new BoxError ( BoxError . FS _ERROR , ` Could not write private key: ${ safe . error . message } ` ) ;
2021-05-18 16:49:39 +02:00
type = 'fuse.sshfs' ;
2021-06-24 16:59:47 -07:00
what = ` ${ mountOptions . user } @ ${ mountOptions . host } : ${ mountOptions . remoteDir } ` ;
2021-06-24 15:10:00 -07:00
options = ` allow_other,port= ${ mountOptions . port } ,IdentityFile= ${ keyFilePath } ,StrictHostKeyChecking=no,reconnect ` ; // allow_other means non-root users can access it
2025-01-12 18:02:06 +01:00
dependsOn = 'network-online.target' ;
2021-05-18 16:49:39 +02:00
break ;
2021-05-14 15:07:29 -07:00
}
2025-01-12 17:41:17 +01:00
case exports . MOUNT _TYPE _FILESYSTEM :
case exports . MOUNT _TYPE _MOUNTPOINT :
2021-05-19 09:50:50 -07:00
return ;
}
2021-05-14 15:07:29 -07:00
2025-08-01 15:48:09 +02:00
return ejs . render ( SYSTEMD _MOUNT _EJS , { description , what , where : hostPath , options , type , dependsOn } ) ;
2021-05-14 15:07:29 -07:00
}
2021-09-28 11:43:15 -07:00
async function removeMount ( mount ) {
assert . strictEqual ( typeof mount , 'object' ) ;
2021-06-21 12:11:05 -07:00
2025-11-06 16:25:04 +01:00
const { hostPath , mountType } = mount ;
2021-05-14 15:07:29 -07:00
2021-05-17 16:23:24 -07:00
if ( constants . TEST ) return ;
2025-07-16 21:53:22 +02:00
await safe ( shell . sudo ( [ RM _MOUNT _CMD , hostPath ] , { } ) , { debug } ) ; // ignore any error
2021-06-21 12:11:05 -07:00
2025-01-12 17:41:17 +01:00
if ( mountType === exports . MOUNT _TYPE _SSHFS ) {
2025-11-06 16:25:04 +01:00
const keyFilePath = path . join ( paths . SSHFS _KEYS _DIR , ` identity_file_ ${ path . basename ( hostPath ) } ` ) ;
2021-06-21 12:11:05 -07:00
safe . fs . unlinkSync ( keyFilePath ) ;
2025-01-12 17:41:17 +01:00
} else if ( mountType === exports . MOUNT _TYPE _CIFS ) {
2024-10-16 10:25:07 +02:00
const out = await shell . spawn ( 'systemd-escape' , [ '-p' , hostPath ] , { encoding : 'utf8' } ) ;
2022-03-29 20:57:09 -07:00
const credentialsFilePath = path . join ( paths . CIFS _CREDENTIALS _DIR , ` ${ out . trim ( ) } .cred ` ) ;
safe . fs . unlinkSync ( credentialsFilePath ) ;
2021-06-21 12:11:05 -07:00
}
2021-05-14 15:07:29 -07:00
}
async function getStatus ( mountType , hostPath ) {
assert . strictEqual ( typeof mountType , 'string' ) ;
assert . strictEqual ( typeof hostPath , 'string' ) ;
2025-11-05 16:38:11 +01:00
if ( mountType === exports . MOUNT _TYPE _FILESYSTEM ) {
const exists = safe . fs . existsSync ( hostPath ) ;
2025-11-13 12:26:51 +01:00
return { state : exists ? 'active' : 'inactive' , message : exists ? '' : ` ${ hostPath } not found ` } ;
2025-11-05 16:38:11 +01:00
}
2021-06-24 23:01:18 -07:00
2025-04-09 15:48:45 +02:00
const [ error ] = await safe ( shell . spawn ( 'mountpoint' , [ '-q' , '--' , hostPath ] , { timeout : 5000 , encoding : 'utf8' } ) ) ;
2024-02-20 21:38:54 +01:00
const state = error ? 'inactive' : 'active' ;
2021-07-09 17:50:27 -07:00
2025-11-05 16:38:11 +01:00
if ( mountType === 'mountpoint' ) return { state , message : state === 'active' ? '' : 'Not mounted' } ;
2021-05-14 15:07:29 -07:00
2021-07-09 17:50:27 -07:00
// we used to rely on "systemctl show -p ActiveState" output before but some mounts like sshfs.fuse show the status as "active" event though the mount commant failed (on ubuntu 18)
2025-11-05 16:38:11 +01:00
let message = '' ;
2021-05-14 15:07:29 -07:00
if ( state !== 'active' ) { // find why it failed
2024-10-16 10:25:07 +02:00
const unitName = await shell . spawn ( 'systemd-escape' , [ '-p' , '--suffix=mount' , hostPath ] , { encoding : 'utf8' } ) ;
const logsJson = await shell . spawn ( 'journalctl' , [ '-u' , unitName , '-n' , '10' , '--no-pager' , '-o' , 'json' ] , { encoding : 'utf8' } ) ;
2021-07-09 16:59:57 -07:00
if ( logsJson ) {
const lines = logsJson . trim ( ) . split ( '\n' ) . map ( l => JSON . parse ( l ) ) ; // array of json
let start = - 1 , end = - 1 ; // start and end of error message block
for ( let idx = lines . length - 1 ; idx >= 0 ; idx -- ) { // reverse
const line = lines [ idx ] ;
2021-10-26 14:54:56 +02:00
const match = line [ 'SYSLOG_IDENTIFIER' ] === 'mount' || ( line [ '_EXE' ] && line [ '_EXE' ] . includes ( 'mount' ) ) || ( line [ '_COMM' ] && line [ '_COMM' ] . includes ( 'mount' ) ) ;
2021-07-09 16:59:57 -07:00
if ( match ) {
if ( end === - 1 ) end = idx ;
start = idx ;
continue ;
}
if ( end !== - 1 ) break ; // no match and we already found a block
}
if ( end !== - 1 ) message = lines . slice ( start , end + 1 ) . map ( line => line [ 'MESSAGE' ] ) . join ( '\n' ) ;
2021-05-14 15:07:29 -07:00
}
2022-10-02 16:38:12 +02:00
if ( ! message ) message = ` Could not determine mount failure reason. ${ safe . error ? safe . error . message : '' } ` ;
2021-05-14 15:07:29 -07:00
}
return { state , message } ;
}
2021-05-19 09:50:50 -07:00
2021-09-28 11:43:15 -07:00
async function tryAddMount ( mount , options ) {
2025-08-01 15:48:09 +02:00
assert . strictEqual ( typeof mount , 'object' ) ; // { description, hostPath, mountType, mountOptions }
2021-09-28 11:51:01 -07:00
assert . strictEqual ( typeof options , 'object' ) ; // { timeout, skipCleanup }
2021-05-19 09:50:50 -07:00
2025-08-01 15:48:09 +02:00
assert ( isManagedProvider ( mount . mountType ) ) ;
2021-05-21 17:31:54 -07:00
if ( constants . TEST ) return ;
2024-02-20 23:09:49 +01:00
const mountFileContents = await renderMountFile ( mount ) ;
2025-07-16 21:53:22 +02:00
const [ error ] = await safe ( shell . sudo ( [ ADD _MOUNT _CMD , mountFileContents , options . timeout ] , { } ) ) ;
2021-06-22 09:53:31 -07:00
if ( error && error . code === 2 ) throw new BoxError ( BoxError . MOUNT _ERROR , 'Failed to unmount existing mount' ) ; // at this point, the old mount config is still there
2021-05-21 17:31:54 -07:00
2021-09-28 11:51:01 -07:00
if ( options . skipCleanup ) return ;
2021-09-28 11:43:15 -07:00
const status = await getStatus ( mount . mountType , mount . hostPath ) ;
2021-06-21 12:11:05 -07:00
if ( status . state !== 'active' ) { // cleanup
2021-09-28 11:43:15 -07:00
await removeMount ( mount ) ;
2021-06-21 22:37:32 -07:00
throw new BoxError ( BoxError . MOUNT _ERROR , ` Failed to mount ( ${ status . state } ): ${ status . message } ` ) ;
2021-05-21 17:31:54 -07:00
}
2021-05-19 09:50:50 -07:00
}
2021-10-11 15:51:16 +02:00
2025-08-01 15:48:09 +02:00
async function remount ( hostPath ) {
assert . strictEqual ( typeof hostPath , 'string' ) ;
2021-10-11 15:51:16 +02:00
if ( constants . TEST ) return ;
2025-08-01 15:48:09 +02:00
const [ error ] = await safe ( shell . sudo ( [ REMOUNT _MOUNT _CMD , hostPath ] , { } ) ) ;
2021-10-11 15:51:16 +02:00
if ( error && error . code === 2 ) throw new BoxError ( BoxError . MOUNT _ERROR , 'Failed to remount existing mount' ) ; // at this point, the old mount config is still there
}