2015-07-20 00:09:47 -07:00
'use strict' ;
exports = module . exports = {
2025-07-24 19:02:02 +02:00
get ,
2025-07-25 12:55:14 +02:00
getPrimary ,
2025-07-24 19:02:02 +02:00
list ,
add ,
2025-08-06 10:51:46 +02:00
addDefault ,
2025-07-24 19:02:02 +02:00
del ,
2016-04-10 19:17:44 -07:00
2025-07-24 19:02:02 +02:00
setConfig ,
setLimits ,
setSchedule ,
setRetention ,
2025-07-25 09:43:26 +02:00
setPrimary ,
2025-08-05 11:26:02 +02:00
setEncryption ,
2025-08-04 14:19:49 +02:00
setName ,
2017-09-20 09:57:16 -07:00
2021-01-21 11:31:35 -08:00
removePrivateFields ,
2019-02-09 18:08:10 -08:00
2025-07-24 19:02:02 +02:00
startBackupTask ,
startCleanupTask ,
2021-07-14 11:07:19 -07:00
getSnapshotInfo ,
setSnapshotInfo ,
2021-10-11 17:45:35 +02:00
remount ,
2025-08-04 10:47:00 +02:00
getStatus ,
2024-09-09 17:39:17 +02:00
ensureMounted ,
2025-08-01 14:54:32 +02:00
2025-08-01 20:49:11 +02:00
storageApi ,
2025-08-01 23:20:51 +02:00
createPseudo ,
2015-07-20 00:09:47 -07:00
} ;
2025-08-14 11:17:38 +05:30
const assert = require ( 'node:assert' ) ,
2025-08-01 23:20:51 +02:00
backupFormat = require ( './backupformat.js' ) ,
2025-07-25 01:34:29 +02:00
backups = require ( './backups.js' ) ,
2019-10-22 20:36:20 -07:00
BoxError = require ( './boxerror.js' ) ,
2019-07-25 14:40:52 -07:00
constants = require ( './constants.js' ) ,
2023-08-04 11:24:28 +05:30
cron = require ( './cron.js' ) ,
2024-04-19 18:19:41 +02:00
{ CronTime } = require ( 'cron' ) ,
2025-08-14 11:17:38 +05:30
crypto = require ( 'node:crypto' ) ,
2017-11-22 10:58:07 -08:00
database = require ( './database.js' ) ,
2021-09-10 12:10:10 -07:00
debug = require ( 'debug' ) ( 'box:backups' ) ,
2018-12-09 03:20:00 -08:00
eventlog = require ( './eventlog.js' ) ,
2025-07-24 19:02:02 +02:00
hush = require ( './hush.js' ) ,
remove global lock
Currently, the update/apptask/fullbackup/platformstart take a
global lock and cannot run in parallel. This causes situations
where when a user tries to trigger an apptask, it says "waiting for
backup to finish..." etc
The solution is to let them run in parallel. We need a lock at the
app level as app operations running in parallel would be bad (tm).
In addition, the update task needs a lock just for the update part.
We also need multi-process locks. Running tasks as processes is core
to our "kill" strategy.
Various inter process locks were explored:
* node's IPC mechanism with process.send(). But this only works for direct node.js
children. taskworker is run via sudo and the IPC does not work.
* File lock using O_EXCL. Basic ideas to create lock files. While file creation
can be done atomically, it becomes complicated to clean up lock files when
the tasks crash. We need a way to know what locks were held by the crashing task.
flock and friends are not built-into node.js
* sqlite/redis were options but introduce additional deps
* Settled on MySQL based locking. Initial plan was to have row locks
or table locks. Each row is a kind of lock. While implementing, it was found that
we need many types of locks (and not just update lock and app locks). For example,
we need locks for each task type, so that only one task type is active at a time.
* Instead of rows, we can just lock table and have a json blob in it. This hit a road
block that LOCK TABLE is per session and our db layer cannot handle this easily! i.e
when issing two db.query() it might use two different connections from the pool. We have to
expose the connection, release connection etc.
* Next idea was atomic blob update of the blob checking if old blob was same. This approach,
was finally refined into a version field.
Phew!
2024-12-07 14:35:45 +01:00
locks = require ( './locks.js' ) ,
2025-08-14 11:17:38 +05:30
path = require ( 'node:path' ) ,
2016-04-10 19:17:44 -07:00
paths = require ( './paths.js' ) ,
2017-04-21 14:07:10 -07:00
safe = require ( 'safetydance' ) ,
2025-07-28 12:53:27 +02:00
tasks = require ( './tasks.js' ) ;
2025-07-24 19:02:02 +02:00
2025-08-01 14:54:32 +02:00
// format: rsync or tgz
// provider: used to determine the api provider
// config: depends on the 'provider' field. 'provider' is not stored in config object. but it is injected when calling the api backends
// s3 providers - accessKeyId, secretAccessKey, bucket, prefix etc . see s3.js
// gcs - bucket, prefix, projectId, credentials . see gcs.js
// ext4/xfs/disk (managed providers) - mountOptions (diskPath), prefix, noHardlinks. disk is legacy.
// nfs/cifs/sshfs (managed providers) - mountOptions (host/username/password/seal/privateKey etc), prefix, noHardlinks
2025-08-02 10:37:37 +02:00
// filesystem - backupDir, noHardlinks
2025-08-01 14:54:32 +02:00
// mountpoint - mountPoint, prefix, noHardlinks
// encryption: 'encryptionPassword' and 'encryptedFilenames' is converted into an 'encryption' object using hush.js. Password is lost forever after conversion.
2025-08-11 19:30:22 +05:30
const BACKUP _TARGET _FIELDS = [ 'id' , 'name' , 'provider' , 'configJson' , 'limitsJson' , 'retentionJson' , 'schedule' , 'encryptionJson' , 'format' , 'main' , 'creationTime' , 'ts' , 'integrityKeyPairJson' ] . join ( ',' ) ;
2025-07-24 19:02:02 +02:00
2025-08-01 14:54:32 +02:00
function storageApi ( backupTarget ) {
assert . strictEqual ( typeof backupTarget , 'object' ) ;
switch ( backupTarget . provider ) {
case 'nfs' : return require ( './storage/filesystem.js' ) ;
case 'cifs' : return require ( './storage/filesystem.js' ) ;
case 'sshfs' : return require ( './storage/filesystem.js' ) ;
case 'mountpoint' : return require ( './storage/filesystem.js' ) ;
case 'disk' : return require ( './storage/filesystem.js' ) ;
case 'ext4' : return require ( './storage/filesystem.js' ) ;
case 's3' : return require ( './storage/s3.js' ) ;
case 'gcs' : return require ( './storage/gcs.js' ) ;
case 'filesystem' : return require ( './storage/filesystem.js' ) ;
case 'minio' : return require ( './storage/s3.js' ) ;
case 's3-v4-compat' : return require ( './storage/s3.js' ) ;
case 'digitalocean-spaces' : return require ( './storage/s3.js' ) ;
case 'exoscale-sos' : return require ( './storage/s3.js' ) ;
case 'wasabi' : return require ( './storage/s3.js' ) ;
case 'scaleway-objectstorage' : return require ( './storage/s3.js' ) ;
case 'backblaze-b2' : return require ( './storage/s3.js' ) ;
case 'cloudflare-r2' : return require ( './storage/s3.js' ) ;
case 'linode-objectstorage' : return require ( './storage/s3.js' ) ;
case 'ovh-objectstorage' : return require ( './storage/s3.js' ) ;
case 'ionos-objectstorage' : return require ( './storage/s3.js' ) ;
case 'idrive-e2' : return require ( './storage/s3.js' ) ;
case 'vultr-objectstorage' : return require ( './storage/s3.js' ) ;
case 'upcloud-objectstorage' : return require ( './storage/s3.js' ) ;
case 'contabo-objectstorage' : return require ( './storage/s3.js' ) ;
case 'hetzner-objectstorage' : return require ( './storage/s3.js' ) ;
case 'noop' : return require ( './storage/noop.js' ) ;
default : throw new BoxError ( BoxError . BAD _FIELD , ` Unknown provider: ${ backupTarget . provider } ` ) ;
}
}
2021-07-14 11:07:19 -07:00
function postProcess ( result ) {
assert . strictEqual ( typeof result , 'object' ) ;
2016-04-10 19:17:44 -07:00
2025-07-24 13:19:27 +02:00
result . config = result . configJson ? safe . JSON . parse ( result . configJson ) : { } ;
delete result . configJson ;
2025-07-28 11:01:46 +02:00
result . limits = safe . JSON . parse ( result . limitsJson ) || { } ;
2025-07-24 13:19:27 +02:00
delete result . limitsJson ;
2025-07-28 11:01:46 +02:00
result . retention = safe . JSON . parse ( result . retentionJson ) || { } ;
2025-07-24 13:19:27 +02:00
delete result . retentionJson ;
result . encryption = result . encryptionJson ? safe . JSON . parse ( result . encryptionJson ) : null ;
delete result . encryptionJson ;
2025-08-11 19:30:22 +05:30
result . integrityKeyPair = result . integrityKeyPairJson ? safe . JSON . parse ( result . integrityKeyPairJson ) : null ;
delete result . integrityKeyPairJson ;
2025-07-25 09:43:26 +02:00
result . primary = ! ! result . main ; // primary is a reserved keyword in mysql
delete result . main ;
2025-07-24 13:19:27 +02:00
return result ;
}
2025-07-24 19:02:02 +02:00
function removePrivateFields ( target ) {
assert . strictEqual ( typeof target , 'object' ) ;
2025-07-28 11:45:10 +02:00
target . encrypted = target . encryption !== null ;
target . encryptedFilenames = target . encryption ? . encryptedFilenames || false ;
2025-08-05 11:26:02 +02:00
target . encryptionPasswordHint = target . encryption ? . encryptionPasswordHint || null ;
2025-07-28 11:45:10 +02:00
delete target . encryption ;
2025-08-11 19:30:22 +05:30
delete target . integrityKeyPair . privateKey ;
2025-07-25 11:29:00 +02:00
delete target . config . rootPath ;
2025-08-01 14:54:32 +02:00
target . config = storageApi ( target ) . removePrivateFields ( target . config ) ;
2025-07-25 11:29:00 +02:00
return target ;
2020-05-12 15:49:43 -07:00
}
2025-08-04 14:19:49 +02:00
function validateName ( name ) {
assert . strictEqual ( typeof name , 'string' ) ;
2023-07-12 10:01:53 +05:30
2025-08-04 15:00:21 +02:00
if ( name . length === 0 ) return new BoxError ( BoxError . BAD _FIELD , 'name cannot be empty' ) ;
2025-08-04 14:19:49 +02:00
if ( name . length > 48 ) return new BoxError ( BoxError . BAD _FIELD , 'name too long' ) ;
2025-07-24 19:02:02 +02:00
}
function validateSchedule ( schedule ) {
assert . strictEqual ( typeof schedule , 'string' ) ;
if ( schedule === constants . CRON _PATTERN _NEVER ) return null ;
const job = safe . safeCall ( function ( ) { return new CronTime ( schedule ) ; } ) ;
2023-07-12 10:01:53 +05:30
if ( ! job ) return new BoxError ( BoxError . BAD _FIELD , 'Invalid schedule pattern' ) ;
2025-07-24 19:02:02 +02:00
return null ;
}
function validateRetention ( retention ) {
assert . strictEqual ( typeof retention , 'object' ) ;
2023-07-12 10:01:53 +05:30
if ( ! retention ) return new BoxError ( BoxError . BAD _FIELD , 'retention is required' ) ;
if ( ! [ 'keepWithinSecs' , 'keepDaily' , 'keepWeekly' , 'keepMonthly' , 'keepYearly' ] . find ( k => ! ! retention [ k ] ) ) return new BoxError ( BoxError . BAD _FIELD , 'retention properties missing' ) ;
if ( 'keepWithinSecs' in retention && typeof retention . keepWithinSecs !== 'number' ) return new BoxError ( BoxError . BAD _FIELD , 'retention.keepWithinSecs must be a number' ) ;
if ( 'keepDaily' in retention && typeof retention . keepDaily !== 'number' ) return new BoxError ( BoxError . BAD _FIELD , 'retention.keepDaily must be a number' ) ;
if ( 'keepWeekly' in retention && typeof retention . keepWeekly !== 'number' ) return new BoxError ( BoxError . BAD _FIELD , 'retention.keepWeekly must be a number' ) ;
if ( 'keepMonthly' in retention && typeof retention . keepMonthly !== 'number' ) return new BoxError ( BoxError . BAD _FIELD , 'retention.keepMonthly must be a number' ) ;
if ( 'keepYearly' in retention && typeof retention . keepYearly !== 'number' ) return new BoxError ( BoxError . BAD _FIELD , 'retention.keepYearly must be a number' ) ;
2025-07-24 19:02:02 +02:00
return null ;
}
function validateEncryptionPassword ( password ) {
assert . strictEqual ( typeof password , 'string' ) ;
if ( password . length < 8 ) return new BoxError ( BoxError . BAD _FIELD , 'password must be atleast 8 characters' ) ;
2023-07-12 10:01:53 +05:30
}
2025-07-24 19:02:02 +02:00
async function list ( page , perPage ) {
assert ( typeof page === 'number' && page > 0 ) ;
assert ( typeof perPage === 'number' && perPage > 0 ) ;
2025-08-04 14:19:49 +02:00
const results = await database . query ( ` SELECT ${ BACKUP _TARGET _FIELDS } FROM backupTargets ORDER BY name DESC LIMIT ?,? ` , [ ( page - 1 ) * perPage , perPage ] ) ;
2025-07-24 19:02:02 +02:00
results . forEach ( function ( result ) { postProcess ( result ) ; } ) ;
return results ;
}
async function get ( id ) {
const results = await database . query ( ` SELECT ${ BACKUP _TARGET _FIELDS } FROM backupTargets WHERE id=? ` , [ id ] ) ;
if ( results . length === 0 ) return null ;
return postProcess ( results [ 0 ] ) ;
}
2025-07-25 12:55:14 +02:00
async function getPrimary ( ) {
const results = await database . query ( ` SELECT ${ BACKUP _TARGET _FIELDS } FROM backupTargets WHERE main=? ` , [ true ] ) ;
if ( results . length === 0 ) return null ;
return postProcess ( results [ 0 ] ) ;
}
2025-07-24 19:02:02 +02:00
async function update ( target , data ) {
assert . strictEqual ( typeof target , 'object' ) ;
assert ( data && typeof data === 'object' ) ;
const args = [ ] ;
const fields = [ ] ;
for ( const k in data ) {
2025-08-04 14:19:49 +02:00
if ( k === 'name' || k === 'schedule' || k === 'main' ) { // format, provider cannot be updated
2025-07-24 19:02:02 +02:00
fields . push ( k + ' = ?' ) ;
args . push ( data [ k ] ) ;
} else if ( k === 'config' || k === 'limits' || k === 'retention' ) { // encryption cannot be updated
fields . push ( ` ${ k } JSON = ? ` ) ;
args . push ( JSON . stringify ( data [ k ] ) ) ;
}
}
args . push ( target . id ) ;
const [ updateError , result ] = await safe ( database . query ( 'UPDATE backupTargets SET ' + fields . join ( ', ' ) + ' WHERE id = ?' , args ) ) ;
if ( updateError ) throw updateError ;
if ( result . affectedRows !== 1 ) throw new BoxError ( BoxError . NOT _FOUND , 'Target not found' ) ;
}
2025-07-25 12:08:33 +02:00
async function setSchedule ( backupTarget , schedule , auditSource ) {
assert . strictEqual ( typeof backupTarget , 'object' ) ;
2025-07-24 19:02:02 +02:00
assert . strictEqual ( typeof schedule , 'string' ) ;
2025-07-25 12:08:33 +02:00
assert . strictEqual ( typeof auditSource , 'object' ) ;
2025-07-24 19:02:02 +02:00
const error = await validateSchedule ( schedule ) ;
if ( error ) throw error ;
2025-07-25 12:08:33 +02:00
await update ( backupTarget , { schedule } ) ;
2025-07-24 19:02:02 +02:00
2025-07-25 12:08:33 +02:00
await cron . handleBackupScheduleChanged ( backupTarget ) ;
await eventlog . add ( eventlog . ACTION _BACKUP _TARGET _UPDATE , auditSource , { backupTarget , schedule } ) ;
2025-07-24 19:02:02 +02:00
}
2025-07-25 12:08:33 +02:00
async function setLimits ( backupTarget , limits , auditSource ) {
assert . strictEqual ( typeof backupTarget , 'object' ) ;
2025-07-24 19:02:02 +02:00
assert . strictEqual ( typeof limits , 'object' ) ;
2025-07-25 12:08:33 +02:00
assert . strictEqual ( typeof auditSource , 'object' ) ;
2025-07-24 19:02:02 +02:00
2025-07-25 12:08:33 +02:00
await update ( backupTarget , { limits } ) ;
await eventlog . add ( eventlog . ACTION _BACKUP _TARGET _UPDATE , auditSource , { backupTarget , limits } ) ;
2025-07-24 19:02:02 +02:00
}
2025-07-25 12:08:33 +02:00
async function setRetention ( backupTarget , retention , auditSource ) {
assert . strictEqual ( typeof backupTarget , 'object' ) ;
2025-07-24 19:02:02 +02:00
assert . strictEqual ( typeof retention , 'object' ) ;
2025-07-25 12:08:33 +02:00
assert . strictEqual ( typeof auditSource , 'object' ) ;
2025-07-24 19:02:02 +02:00
const error = await validateRetention ( retention ) ;
if ( error ) throw error ;
2025-07-25 12:08:33 +02:00
await update ( backupTarget , { retention } ) ;
await eventlog . add ( eventlog . ACTION _BACKUP _TARGET _UPDATE , auditSource , { backupTarget , retention } ) ;
2025-07-24 19:02:02 +02:00
}
2025-07-25 12:08:33 +02:00
async function setPrimary ( backupTarget , auditSource ) {
assert . strictEqual ( typeof backupTarget , 'object' ) ;
assert . strictEqual ( typeof auditSource , 'object' ) ;
2025-07-25 09:43:26 +02:00
const queries = [
2025-07-25 12:08:33 +02:00
{ query : 'SELECT 1 FROM backupTargets WHERE id=? FOR UPDATE' , args : [ backupTarget . id ] } , // ensure this exists!
2025-07-25 09:43:26 +02:00
{ query : 'UPDATE backupTargets SET main=?' , args : [ false ] } ,
2025-07-25 12:08:33 +02:00
{ query : 'UPDATE backupTargets SET main=? WHERE id=?' , args : [ true , backupTarget . id ] }
2025-07-25 09:43:26 +02:00
] ;
const [ error , result ] = await safe ( database . transaction ( queries ) ) ;
if ( error ) throw error ;
if ( result [ 2 ] . affectedRows !== 1 ) throw new BoxError ( BoxError . NOT _FOUND , 'Target not found' ) ;
2025-07-25 12:08:33 +02:00
await eventlog . add ( eventlog . ACTION _BACKUP _TARGET _UPDATE , auditSource , { backupTarget , primary : true } ) ;
2025-07-25 09:43:26 +02:00
}
2025-08-05 11:26:02 +02:00
async function setEncryption ( backupTarget , data , auditSource ) {
assert . strictEqual ( typeof backupTarget , 'object' ) ;
assert . strictEqual ( typeof data , 'object' ) ;
assert . strictEqual ( typeof auditSource , 'object' ) ;
let encryption = null ;
if ( data . encryptionPassword ) {
const encryptionPasswordError = validateEncryptionPassword ( data . encryptionPassword ) ;
if ( encryptionPasswordError ) throw encryptionPasswordError ;
encryption = hush . generateEncryptionKeysSync ( data . encryptionPassword ) ;
encryption . encryptedFilenames = ! ! data . encryptedFilenames ;
encryption . encryptionPasswordHint = data . encryptionPasswordHint || '' ;
}
const queries = [
2025-08-06 12:52:49 +02:00
{ query : 'DELETE FROM backups WHERE targetId=?' , args : [ backupTarget . id ] } ,
{ query : 'UPDATE backupTargets SET encryptionJson=? WHERE id=?' , args : [ encryption ? JSON . stringify ( encryption ) : null , backupTarget . id ] } ,
2025-08-05 11:26:02 +02:00
] ;
const [ error , result ] = await safe ( database . transaction ( queries ) ) ;
if ( error ) throw error ;
if ( result [ 1 ] . affectedRows !== 1 ) throw new BoxError ( BoxError . NOT _FOUND , 'Target not found' ) ;
await eventlog . add ( eventlog . ACTION _BACKUP _TARGET _UPDATE , auditSource , { backupTarget , encryption : ! ! encryption } ) ;
}
2025-08-04 14:19:49 +02:00
async function setName ( backupTarget , name , auditSource ) {
assert . strictEqual ( typeof backupTarget , 'object' ) ;
assert . strictEqual ( typeof name , 'string' ) ;
assert . strictEqual ( typeof auditSource , 'object' ) ;
const nameError = validateName ( name ) ;
if ( nameError ) throw nameError ;
await update ( backupTarget , { name } ) ;
}
2025-07-30 11:19:07 +02:00
async function del ( backupTarget , auditSource ) {
assert . strictEqual ( typeof backupTarget , 'object' ) ;
2025-07-25 12:08:33 +02:00
assert . strictEqual ( typeof auditSource , 'object' ) ;
2025-07-24 19:02:02 +02:00
2025-08-01 14:54:32 +02:00
await safe ( storageApi ( backupTarget ) . teardown ( backupTarget . config ) , { debug } ) ; // ignore error
2025-07-30 11:19:07 +02:00
if ( backupTarget . primary ) throw new BoxError ( BoxError . CONFLICT , 'Cannot delete the primary backup target' ) ;
2025-07-24 19:02:02 +02:00
2025-07-25 09:43:26 +02:00
const queries = [
2025-08-04 10:16:19 +02:00
{ query : 'DELETE FROM archives WHERE backupId IN (SELECT id FROM backups WHERE targetId=?)' , args : [ backupTarget . id ] } ,
{ query : 'DELETE FROM backups WHERE targetId=?' , args : [ backupTarget . id ] } ,
2025-07-30 11:19:07 +02:00
{ query : 'DELETE FROM backupTargets WHERE id=? AND main=?' , args : [ backupTarget . id , false ] } , // cannot delete primary
2025-07-25 09:43:26 +02:00
] ;
2025-07-24 19:02:02 +02:00
const [ error , result ] = await safe ( database . transaction ( queries ) ) ;
if ( error && error . code === 'ER_NO_REFERENCED_ROW_2' ) throw new BoxError ( BoxError . NOT _FOUND , error ) ;
if ( error ) throw error ;
2025-08-04 10:16:19 +02:00
if ( result [ 2 ] . affectedRows !== 1 ) throw new BoxError ( BoxError . NOT _FOUND , 'Target not found' ) ;
2025-07-30 11:19:07 +02:00
await eventlog . add ( eventlog . ACTION _BACKUP _TARGET _REMOVE , auditSource , { backupTarget : backupTarget } ) ;
2025-07-24 19:02:02 +02:00
2025-07-30 11:19:07 +02:00
backupTarget . schedule = constants . CRON _PATTERN _NEVER ;
await cron . handleBackupScheduleChanged ( backupTarget ) ;
2025-07-25 08:36:09 +02:00
2025-07-30 11:19:07 +02:00
const infoDir = path . join ( paths . BACKUP _INFO _DIR , backupTarget . id ) ;
2025-08-14 13:43:41 +05:30
safe . fs . rmSync ( infoDir , { recursive : true } ) ;
2025-07-24 19:02:02 +02:00
}
async function startBackupTask ( target , auditSource ) {
assert . strictEqual ( typeof target , 'object' ) ;
2025-07-25 14:46:55 +02:00
const [ error ] = await safe ( locks . acquire ( ` ${ locks . TYPE _FULL _BACKUP _TASK _PREFIX } ${ target . id } ` ) ) ;
remove global lock
Currently, the update/apptask/fullbackup/platformstart take a
global lock and cannot run in parallel. This causes situations
where when a user tries to trigger an apptask, it says "waiting for
backup to finish..." etc
The solution is to let them run in parallel. We need a lock at the
app level as app operations running in parallel would be bad (tm).
In addition, the update task needs a lock just for the update part.
We also need multi-process locks. Running tasks as processes is core
to our "kill" strategy.
Various inter process locks were explored:
* node's IPC mechanism with process.send(). But this only works for direct node.js
children. taskworker is run via sudo and the IPC does not work.
* File lock using O_EXCL. Basic ideas to create lock files. While file creation
can be done atomically, it becomes complicated to clean up lock files when
the tasks crash. We need a way to know what locks were held by the crashing task.
flock and friends are not built-into node.js
* sqlite/redis were options but introduce additional deps
* Settled on MySQL based locking. Initial plan was to have row locks
or table locks. Each row is a kind of lock. While implementing, it was found that
we need many types of locks (and not just update lock and app locks). For example,
we need locks for each task type, so that only one task type is active at a time.
* Instead of rows, we can just lock table and have a json blob in it. This hit a road
block that LOCK TABLE is per session and our db layer cannot handle this easily! i.e
when issing two db.query() it might use two different connections from the pool. We have to
expose the connection, release connection etc.
* Next idea was atomic blob update of the blob checking if old blob was same. This approach,
was finally refined into a version field.
Phew!
2024-12-07 14:35:45 +01:00
if ( error ) throw new BoxError ( BoxError . BAD _STATE , ` Another backup task is in progress: ${ error . message } ` ) ;
2020-05-10 21:40:25 -07:00
2025-07-24 19:02:02 +02:00
const memoryLimit = target . limits ? . memoryLimit ? Math . max ( target . limits . memoryLimit / 1024 / 1024 , 1024 ) : 1024 ;
2018-07-27 06:55:54 -07:00
2025-07-24 19:02:02 +02:00
const taskId = await tasks . add ( ` ${ tasks . TASK _FULL _BACKUP _PREFIX } ${ target . id } ` , [ target . id , { /* options */ } ] ) ;
2018-07-27 06:55:54 -07:00
2021-09-10 12:10:10 -07:00
await eventlog . add ( eventlog . ACTION _BACKUP _START , auditSource , { taskId } ) ;
2020-09-30 20:03:46 -07:00
2025-06-17 18:54:12 +02:00
// background
tasks . startTask ( taskId , { timeout : 24 * 60 * 60 * 1000 /* 24 hours */ , nice : 15 , memoryLimit , oomScoreAdjust : - 999 } )
. then ( async ( backupId ) => {
2025-07-25 01:34:29 +02:00
const backup = await backups . get ( backupId ) ;
2025-06-17 18:54:12 +02:00
await eventlog . add ( eventlog . ACTION _BACKUP _FINISH , auditSource , { taskId , backupId , remotePath : backup . remotePath } ) ;
} )
. catch ( async ( error ) => {
const timedOut = error . code === tasks . ETIMEOUT ;
await safe ( eventlog . add ( eventlog . ACTION _BACKUP _FINISH , auditSource , { taskId , errorMessage : error . message , timedOut } ) ) ;
} )
. finally ( async ( ) => {
2025-07-25 14:46:55 +02:00
await locks . release ( ` ${ locks . TYPE _FULL _BACKUP _TASK _PREFIX } ${ target . id } ` ) ;
2025-06-17 18:54:12 +02:00
await locks . releaseByTaskId ( taskId ) ;
} ) ;
2021-09-10 12:10:10 -07:00
return taskId ;
2021-07-14 11:07:19 -07:00
}
2018-07-27 11:46:42 -07:00
2025-07-30 11:19:07 +02:00
async function removeCacheFiles ( backupTarget ) {
assert . strictEqual ( typeof backupTarget , 'object' ) ;
2017-09-20 09:57:16 -07:00
2025-07-30 11:19:07 +02:00
const infoDir = path . join ( paths . BACKUP _INFO _DIR , backupTarget . id ) ;
const files = safe . fs . readdirSync ( infoDir ) ;
if ( ! files ) throw new BoxError ( BoxError . FS _ERROR , ` Unable to access ${ infoDir } : ${ safe . error . message } ` ) ;
for ( const f of files ) {
if ( ! f . endsWith ( '.sync.cache' ) ) continue ;
safe . fs . unlinkSync ( path . join ( infoDir , f ) ) ;
}
2020-05-10 21:40:25 -07:00
}
2025-07-30 11:19:07 +02:00
// keeps track of contents of the snapshot directory. this provides a way to clean up backups of uninstalled apps
async function getSnapshotInfo ( backupTarget ) {
assert . strictEqual ( typeof backupTarget , 'object' ) ;
2018-02-22 10:58:56 -08:00
2025-07-30 11:19:07 +02:00
const snapshotFilePath = path . join ( paths . BACKUP _INFO _DIR , backupTarget . id , constants . SNAPSHOT _INFO _FILENAME ) ;
const contents = safe . fs . readFileSync ( snapshotFilePath , 'utf8' ) ;
2021-07-14 11:07:19 -07:00
const info = safe . JSON . parse ( contents ) ;
2025-07-30 11:19:07 +02:00
return info || { } ;
2017-09-22 14:40:37 -07:00
}
2021-09-26 18:37:04 -07:00
// keeps track of contents of the snapshot directory. this provides a way to clean up backups of uninstalled apps
2025-07-30 11:19:07 +02:00
async function setSnapshotInfo ( backupTarget , id , info ) {
assert . strictEqual ( typeof backupTarget , 'object' ) ;
assert . strictEqual ( typeof id , 'string' ) ; // 'box', 'mail' or appId
2021-07-14 11:07:19 -07:00
assert . strictEqual ( typeof info , 'object' ) ;
2017-09-22 14:40:37 -07:00
2025-07-30 11:19:07 +02:00
const infoDir = path . join ( paths . BACKUP _INFO _DIR , backupTarget . id ) ;
const snapshotFilePath = path . join ( infoDir , constants . SNAPSHOT _INFO _FILENAME ) ;
const contents = safe . fs . readFileSync ( snapshotFilePath , 'utf8' ) ;
const data = safe . JSON . parse ( contents ) || { } ;
2021-07-14 11:07:19 -07:00
if ( info ) data [ id ] = info ; else delete data [ id ] ;
2025-07-30 11:19:07 +02:00
if ( ! safe . fs . writeFileSync ( snapshotFilePath , JSON . stringify ( data , null , 4 ) , 'utf8' ) ) {
2021-09-16 13:59:03 -07:00
throw new BoxError ( BoxError . FS _ERROR , safe . error . message ) ;
2019-01-17 09:53:51 -08:00
}
2025-07-30 11:19:07 +02:00
if ( ! info ) { // unlink the cache files
safe . fs . unlinkSync ( path . join ( infoDir , ` ${ id } .sync.cache ` ) ) ;
safe . fs . unlinkSync ( path . join ( infoDir , ` ${ id } .sync.cache.new ` ) ) ;
}
2017-09-22 14:40:37 -07:00
}
2025-07-25 14:54:51 +02:00
async function startCleanupTask ( backupTarget , auditSource ) {
assert . strictEqual ( typeof backupTarget , 'object' ) ;
2021-07-14 11:07:19 -07:00
assert . strictEqual ( typeof auditSource , 'object' ) ;
2017-09-19 20:27:36 -07:00
2025-07-25 14:54:51 +02:00
const taskId = await tasks . add ( ` ${ tasks . TASK _CLEAN _BACKUPS _PREFIX } ${ backupTarget . id } ` , [ backupTarget . id ] ) ;
2017-09-19 20:27:36 -07:00
2025-06-17 18:54:12 +02:00
// background
tasks . startTask ( taskId , { } )
. then ( async ( result ) => { // { removedBoxBackupPaths, removedAppBackupPaths, removedMailBackupPaths, missingBackupPaths }
await eventlog . add ( eventlog . ACTION _BACKUP _CLEANUP _FINISH , auditSource , { taskId , errorMessage : null , ... result } ) ;
} )
. catch ( async ( error ) => {
await eventlog . add ( eventlog . ACTION _BACKUP _CLEANUP _FINISH , auditSource , { taskId , errorMessage : error . message } ) ;
} ) ;
2018-12-20 11:41:38 -08:00
2021-07-14 11:07:19 -07:00
return taskId ;
2017-09-23 14:27:35 -07:00
}
2025-07-24 19:02:02 +02:00
async function remount ( target ) {
assert . strictEqual ( typeof target , 'object' ) ;
2021-10-11 17:45:35 +02:00
2025-08-01 14:54:32 +02:00
await storageApi ( target ) . setup ( target . config ) ;
2021-10-11 17:45:35 +02:00
}
2023-04-30 17:21:18 +02:00
2025-08-04 10:47:00 +02:00
async function getStatus ( target ) {
2025-07-24 19:02:02 +02:00
assert . strictEqual ( typeof target , 'object' ) ;
2023-04-30 17:21:18 +02:00
2025-08-04 10:47:00 +02:00
return await storageApi ( target ) . getStatus ( target . config ) ;
2023-04-30 17:21:18 +02:00
}
2023-08-04 11:24:28 +05:30
2025-07-24 19:02:02 +02:00
async function ensureMounted ( target ) {
assert . strictEqual ( typeof target , 'object' ) ;
2025-08-04 10:47:00 +02:00
const status = await getStatus ( target ) ;
2024-09-09 17:39:17 +02:00
if ( status . state === 'active' ) return status ;
await remount ( ) ;
2025-08-04 10:47:00 +02:00
return await getStatus ( target ) ;
2023-08-04 11:24:28 +05:30
}
2025-07-25 12:08:33 +02:00
async function setConfig ( backupTarget , newConfig , auditSource ) {
assert . strictEqual ( typeof backupTarget , 'object' ) ;
2025-07-24 19:02:02 +02:00
assert . strictEqual ( typeof newConfig , 'object' ) ;
2025-07-25 12:08:33 +02:00
assert . strictEqual ( typeof auditSource , 'object' ) ;
2023-08-04 11:24:28 +05:30
2025-07-24 19:02:02 +02:00
if ( constants . DEMO ) throw new BoxError ( BoxError . BAD _STATE , 'Not allowed in demo mode' ) ;
2023-09-29 06:49:55 +05:30
2025-07-25 12:08:33 +02:00
const oldConfig = backupTarget . config ;
2023-09-29 06:49:55 +05:30
2025-08-01 14:54:32 +02:00
storageApi ( backupTarget ) . injectPrivateFields ( newConfig , oldConfig ) ;
2024-06-11 14:32:31 +02:00
2025-07-24 19:02:02 +02:00
debug ( 'setConfig: validating new storage configuration' ) ;
2025-08-01 18:55:04 +02:00
const sanitizedConfig = await storageApi ( backupTarget ) . verifyConfig ( { id : backupTarget . id , provider : backupTarget . provider , config : newConfig } ) ;
2023-09-29 06:49:55 +05:30
2025-07-24 19:02:02 +02:00
debug ( 'setConfig: clearing backup cache' ) ;
2025-07-30 11:19:07 +02:00
// FIXME: this cleans up the cache files in case the bucket or the prefix changes and the destination already has something there
// however, this will also resync if just the credentials change
await removeCacheFiles ( backupTarget ) ;
2025-07-25 12:08:33 +02:00
2025-08-01 18:55:04 +02:00
await update ( backupTarget , { config : sanitizedConfig } ) ;
debug ( 'setConfig: setting up new storage configuration' ) ;
await storageApi ( backupTarget ) . setup ( sanitizedConfig ) ;
2023-09-29 06:49:55 +05:30
2025-07-25 12:08:33 +02:00
await eventlog . add ( eventlog . ACTION _BACKUP _TARGET _UPDATE , auditSource , { backupTarget , newConfig } ) ;
2023-09-29 06:49:55 +05:30
}
2025-07-25 12:08:33 +02:00
async function add ( data , auditSource ) {
2025-07-24 19:02:02 +02:00
assert . strictEqual ( typeof data , 'object' ) ;
2025-07-25 12:08:33 +02:00
assert . strictEqual ( typeof auditSource , 'object' ) ;
2023-08-15 08:14:35 +05:30
2024-06-06 11:32:15 +02:00
if ( constants . DEMO ) throw new BoxError ( BoxError . BAD _STATE , 'Not allowed in demo mode' ) ;
2025-08-04 14:19:49 +02:00
const { provider , name , config , format , retention , schedule } = data ; // required
2025-07-24 19:02:02 +02:00
const limits = data . limits || null ,
encryptionPassword = data . encryptionPassword || null ,
2025-08-05 11:26:02 +02:00
encryptedFilenames = data . encryptedFilenames || false ,
encryptionPasswordHint = data . encryptionPasswordHint || null ;
2023-08-04 11:24:28 +05:30
2025-08-01 23:20:51 +02:00
const formatError = backupFormat . validateFormat ( format ) ;
2024-04-09 12:23:43 +02:00
if ( formatError ) throw formatError ;
2025-08-04 14:19:49 +02:00
const nameError = validateName ( name ) ;
if ( nameError ) throw nameError ;
2024-04-09 12:23:43 +02:00
2025-07-24 19:02:02 +02:00
let encryption = null ;
if ( encryptionPassword ) {
const encryptionPasswordError = validateEncryptionPassword ( encryptionPassword ) ;
if ( encryptionPasswordError ) throw encryptionPasswordError ;
encryption = hush . generateEncryptionKeysSync ( encryptionPassword ) ;
encryption . encryptedFilenames = ! ! encryptedFilenames ;
2025-08-05 11:26:02 +02:00
encryption . encryptionPasswordHint = encryptionPasswordHint ;
2024-04-09 12:23:43 +02:00
}
2023-08-15 18:37:21 +05:30
2025-08-11 19:30:22 +05:30
const integrityKeyPair = crypto . generateKeyPairSync ( 'ed25519' , {
publicKeyEncoding : { type : 'spki' , format : 'pem' } ,
privateKeyEncoding : { type : 'pkcs8' , format : 'pem' }
} ) ;
2025-07-28 12:53:27 +02:00
const id = ` bc- ${ crypto . randomUUID ( ) } ` ;
2025-07-30 11:19:07 +02:00
if ( ! safe . fs . mkdirSync ( ` ${ paths . BACKUP _INFO _DIR } / ${ id } ` ) ) throw new BoxError ( BoxError . FS _ERROR , ` Failed to create info dir: ${ safe . error . message } ` ) ;
2025-08-01 18:55:04 +02:00
debug ( 'add: validating new storage configuration' ) ;
const sanitizedConfig = await storageApi ( { provider } ) . verifyConfig ( { id , provider , config } ) ;
2025-08-11 19:30:22 +05:30
await database . query ( 'INSERT INTO backupTargets (id, name, provider, configJson, limitsJson, integrityKeyPairJson, retentionJson, schedule, encryptionJson, format, main) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' ,
[ id , name , provider , JSON . stringify ( sanitizedConfig ) , JSON . stringify ( limits ) , JSON . stringify ( integrityKeyPair ) , JSON . stringify ( retention ) , schedule , JSON . stringify ( encryption ) , format , false ] ) ;
2025-07-25 12:08:33 +02:00
2025-08-01 14:54:32 +02:00
debug ( 'add: setting up new storage configuration' ) ;
2025-08-01 18:55:04 +02:00
await storageApi ( { provider } ) . setup ( sanitizedConfig ) ;
2025-08-01 14:54:32 +02:00
2025-08-04 14:19:49 +02:00
await eventlog . add ( eventlog . ACTION _BACKUP _TARGET _ADD , auditSource , { id , name , provider , config , schedule , format } ) ;
2025-07-25 12:08:33 +02:00
2025-07-24 19:02:02 +02:00
return id ;
2023-08-15 20:24:54 +05:30
}
2025-08-01 23:20:51 +02:00
2025-08-06 10:51:46 +02:00
async function addDefault ( auditSource ) {
assert . strictEqual ( typeof auditSource , 'object' ) ;
debug ( 'addDefault: adding default backup target' ) ;
const defaultBackupTarget = {
name : 'Default' ,
provider : 'filesystem' ,
config : { backupDir : paths . DEFAULT _BACKUP _DIR } ,
retention : { keepWithinSecs : 2 * 24 * 60 * 60 } ,
schedule : '00 00 23 * * *' ,
format : 'tgz'
} ;
defaultBackupTarget . id = await add ( defaultBackupTarget , auditSource ) ;
await setPrimary ( defaultBackupTarget , auditSource ) ;
}
2025-08-01 23:20:51 +02:00
// creates a backup target object that is not in the database
async function createPseudo ( data ) {
assert . strictEqual ( typeof data , 'object' ) ;
const { id , provider , config , format } = data ; // required
2025-08-05 14:13:39 +02:00
const encryptionPassword = data . encryptionPassword ? ? null ,
encryptedFilenames = ! ! data . encryptedFilenames ;
2025-08-01 23:20:51 +02:00
const formatError = backupFormat . validateFormat ( format ) ;
if ( formatError ) throw formatError ;
let encryption = null ;
if ( encryptionPassword ) {
const encryptionPasswordError = validateEncryptionPassword ( encryptionPassword ) ;
if ( encryptionPasswordError ) throw encryptionPasswordError ;
encryption = hush . generateEncryptionKeysSync ( encryptionPassword ) ;
encryption . encryptedFilenames = ! ! encryptedFilenames ;
2025-08-05 11:26:02 +02:00
encryption . encryptionPasswordHint = '' ;
2025-08-01 23:20:51 +02:00
}
debug ( 'add: validating new storage configuration' ) ;
const sanitizedConfig = await storageApi ( { provider } ) . verifyConfig ( { id , provider , config } ) ;
return { id , format , provider , config : sanitizedConfig , encryption } ;
}