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 ,
2021-10-11 15:51:16 +02:00
remount
2021-05-14 15:07:29 -07:00
} ;
const assert = require ( 'assert' ) ,
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' ) ,
fs = require ( 'fs' ) ,
path = require ( 'path' ) ,
2021-05-18 16:49:39 +02:00
paths = require ( './paths.js' ) ,
2021-05-14 15:07:29 -07:00
safe = require ( 'safetydance' ) ,
shell = require ( './shell.js' ) ;
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
function validateMountOptions ( type , options ) {
assert . strictEqual ( typeof type , 'string' ) ;
assert . strictEqual ( typeof options , 'object' ) ;
switch ( type ) {
2021-06-24 23:01:18 -07:00
case 'filesystem' :
case '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 ;
case 'cifs' :
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 ;
case 'nfs' :
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 ;
2021-05-18 16:49:39 +02:00
case 'sshfs' :
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 ;
2021-05-14 15:07:29 -07:00
case 'ext4' :
2022-06-08 10:32:25 -07:00
case 'xfs' :
2023-08-08 13:21:56 +02:00
case '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' ) ;
return null ;
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 ) {
2023-08-08 13:21:56 +02:00
return provider === 'sshfs' || provider === 'cifs' || provider === 'nfs' || provider === 'ext4' || provider === 'xfs' || provider === 'disk' ;
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
2022-03-29 20:15:55 -07:00
const { name , hostPath , mountType , mountOptions } = mount ;
2021-05-14 15:07:29 -07:00
let options , what , type ;
switch ( mountType ) {
2022-03-29 20:57:09 -07:00
case 'cifs' : {
2024-02-29 10:41:07 +01:00
const out = await shell . execArgs ( 'renderMountFile' , 'systemd-escape' , [ '-p' , hostPath ] , { } ) ; // 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 ` ;
2021-05-14 15:07:29 -07:00
break ;
2022-03-29 20:57:09 -07:00
}
2021-05-14 15:07:29 -07:00
case 'nfs' :
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
2021-05-14 15:07:29 -07:00
break ;
case 'ext4' :
type = 'ext4' ;
what = mountOptions . diskPath ; // like /dev/disk/by-uuid/uuid or /dev/disk/by-id/scsi-id
options = 'discard,defaults,noatime' ;
break ;
2022-06-08 10:32:25 -07:00
case 'xfs' :
type = 'xfs' ;
what = mountOptions . diskPath ; // like /dev/disk/by-uuid/uuid or /dev/disk/by-id/scsi-id
2023-08-08 13:21:56 +02:00
options = 'discard,defaults,noatime' ;
break ;
case 'disk' :
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 ;
2021-05-19 09:50:50 -07:00
case 'sshfs' : {
2021-05-18 16:49:39 +02:00
const keyFilePath = path . join ( paths . SSHFS _KEYS _DIR , ` id_rsa_ ${ mountOptions . host } ` ) ;
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
2021-05-18 16:49:39 +02:00
break ;
2021-05-14 15:07:29 -07:00
}
2021-06-24 23:01:18 -07:00
case 'filesystem' :
case 'mountpoint' :
2021-05-19 09:50:50 -07:00
return ;
}
2021-05-14 15:07:29 -07:00
2021-05-21 17:31:54 -07:00
return ejs . render ( SYSTEMD _MOUNT _EJS , { name , what , where : hostPath , options , type } ) ;
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
2021-09-28 11:43:15 -07:00
const { hostPath , mountType , mountOptions } = mount ;
2021-05-14 15:07:29 -07:00
2021-05-17 16:23:24 -07:00
if ( constants . TEST ) return ;
2021-09-29 20:15:54 -07:00
await safe ( shell . promises . sudo ( 'removeMount' , [ RM _MOUNT _CMD , hostPath ] , { } ) , { debug } ) ; // ignore any error
2021-06-21 12:11:05 -07:00
if ( mountType === 'sshfs' ) {
const keyFilePath = path . join ( paths . SSHFS _KEYS _DIR , ` id_rsa_ ${ mountOptions . host } ` ) ;
safe . fs . unlinkSync ( keyFilePath ) ;
2022-03-29 20:57:09 -07:00
} else if ( mountType === 'cifs' ) {
2024-02-29 09:00:22 +01:00
const out = await shell . execArgs ( 'removeMount' , 'systemd-escape' , [ '-p' , hostPath ] , { } ) ;
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' ) ;
2021-06-24 23:01:18 -07:00
if ( mountType === 'filesystem' ) return { state : 'active' , message : 'Mounted' } ;
2024-02-29 09:00:22 +01:00
const [ error ] = await safe ( shell . execArgs ( 'getVolumeStatus' , 'mountpoint' , [ '-q' , '--' , hostPath ] , { timeout : 5000 } ) ) ;
2024-02-20 21:38:54 +01:00
const state = error ? 'inactive' : 'active' ;
2021-07-09 17:50:27 -07:00
if ( mountType === 'mountpoint' ) return { state , message : state === 'active' ? 'Mounted' : '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)
2021-05-14 15:07:29 -07:00
let message ;
if ( state !== 'active' ) { // find why it failed
2024-02-28 20:37:11 +01:00
const logsJson = await shell . exec ( 'getStatus' , ` journalctl -u $ (systemd-escape -p --suffix=mount ${ hostPath } ) -n 10 --no-pager -o json ` , { shell : '/bin/bash' } ) ;
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
} else {
message = 'Mounted' ;
}
return { state , message } ;
}
2021-05-19 09:50:50 -07:00
2021-09-28 11:43:15 -07:00
async function tryAddMount ( mount , options ) {
assert . strictEqual ( typeof mount , 'object' ) ; // { name, 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
2022-11-03 23:28:02 +01:00
if ( mount . mountType === 'mountpoint' || mount . mountType === 'filesystem' ) return ;
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 ) ;
const [ error ] = await safe ( shell . promises . sudo ( 'addMount' , [ 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
async function remount ( mount ) {
assert . strictEqual ( typeof mount , 'object' ) ; // { name, hostPath, mountType, mountOptions }
2022-11-03 23:28:02 +01:00
if ( mount . mountType === 'mountpoint' || mount . mountType === 'filesystem' ) return ;
2021-10-11 15:51:16 +02:00
if ( constants . TEST ) return ;
const [ error ] = await safe ( shell . promises . sudo ( 'remountMount' , [ REMOUNT _MOUNT _CMD , mount . hostPath ] , { } ) ) ;
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
}