'use strict'; exports = module.exports = { setAutoupdatePattern, getAutoupdatePattern, updateToLatest, update, notifyUpdate }; const apps = require('./apps.js'), assert = require('assert'), AuditSource = require('./auditsource.js'), BoxError = require('./boxerror.js'), backups = require('./backups.js'), backuptask = require('./backuptask.js'), constants = require('./constants.js'), cron = require('./cron.js'), { CronTime } = require('cron'), crypto = require('crypto'), debug = require('debug')('box:updater'), df = require('./df.js'), eventlog = require('./eventlog.js'), fs = require('fs'), locker = require('./locker.js'), os = require('os'), path = require('path'), paths = require('./paths.js'), promiseRetry = require('./promise-retry.js'), safe = require('safetydance'), semver = require('semver'), settings = require('./settings.js'), shell = require('./shell.js'), tasks = require('./tasks.js'), updateChecker = require('./updatechecker.js'); const RELEASES_PUBLIC_KEY = path.join(__dirname, 'releases.gpg'); const UPDATE_CMD = path.join(__dirname, 'scripts/update.sh'); async function setAutoupdatePattern(pattern) { assert.strictEqual(typeof pattern, 'string'); if (pattern !== constants.AUTOUPDATE_PATTERN_NEVER) { // check if pattern is valid const job = safe.safeCall(function () { return new CronTime(pattern); }); if (!job) throw new BoxError(BoxError.BAD_FIELD, 'Invalid pattern'); } await settings.set(settings.AUTOUPDATE_PATTERN_KEY, pattern); await cron.handleAutoupdatePatternChanged(pattern); } async function getAutoupdatePattern() { const pattern = await settings.get(settings.AUTOUPDATE_PATTERN_KEY); return pattern || cron.DEFAULT_AUTOUPDATE_PATTERN; } async function downloadUrl(url, file) { assert.strictEqual(typeof file, 'string'); // do not assert since it comes from the appstore if (typeof url !== 'string') throw new BoxError(BoxError.EXTERNAL_ERROR, `url cannot be download to ${file} as it is not a string`); safe.fs.unlinkSync(file); await promiseRetry({ times: 10, interval: 5000, debug }, async function () { debug(`downloadUrl: downloading ${url} to ${file}`); const [error] = await safe(shell.exec('downloadUrl', `curl -s --fail ${url} -o ${file}`, {})); if (error) throw new BoxError(BoxError.NETWORK_ERROR, `Failed to download ${url}: ${error.message}`); debug('downloadUrl: done'); }); } async function gpgVerify(file, sig) { assert.strictEqual(typeof file, 'string'); assert.strictEqual(typeof sig, 'string'); const cmd = `/usr/bin/gpg --status-fd 1 --no-default-keyring --keyring ${RELEASES_PUBLIC_KEY} --verify ${sig} ${file}`; debug(`gpgVerify: ${cmd}`); const [error, stdout] = await safe(shell.exec('gpgVerify', cmd, {})); if (error) { debug(`gpgVerify: command failed. error: ${error}\n stdout: ${error.stdout}\n stderr: ${error.stderr}`); throw new BoxError(BoxError.NOT_SIGNED, `The signature in ${path.basename(sig)} could not be verified (command failed)`); } if (stdout.indexOf('[GNUPG:] VALIDSIG 0EADB19CDDA23CD0FE71E3470A372F8703C493CC') !== -1) return; // success debug(`gpgVerify: verification of ${sig} failed: ${stdout}\n`); throw new BoxError(BoxError.NOT_SIGNED, `The signature in ${path.basename(sig)} could not be verified (bad sig)`); } async function extractTarball(tarball, dir) { assert.strictEqual(typeof tarball, 'string'); assert.strictEqual(typeof dir, 'string'); debug(`extractTarball: extracting ${tarball} to ${dir}`); const [error] = await safe(shell.exec('extractTarball', `tar -zxf ${tarball} -C ${dir}`, {})); if (error) throw new BoxError(BoxError.FS_ERROR, `Failed to extract release package: ${error.message}`); safe.fs.unlinkSync(tarball); debug('extractTarball: extracted'); } async function verifyUpdateInfo(versionsFile, updateInfo) { assert.strictEqual(typeof versionsFile, 'string'); assert.strictEqual(typeof updateInfo, 'object'); const releases = safe.JSON.parse(safe.fs.readFileSync(versionsFile, 'utf8')) || {}; if (!releases[constants.VERSION]) throw new BoxError(BoxError.EXTERNAL_ERROR, `No version info for ${constants.VERSION}`); if (!releases[constants.VERSION].next) throw new BoxError(BoxError.EXTERNAL_ERROR, `No next version info for ${constants.VERSION}`); const nextVersion = releases[constants.VERSION].next; if (typeof releases[nextVersion] !== 'object' || !releases[nextVersion]) throw new BoxError(BoxError.EXTERNAL_ERROR, 'No next version info'); if (releases[nextVersion].sourceTarballUrl !== updateInfo.sourceTarballUrl) throw new BoxError(BoxError.EXTERNAL_ERROR, 'Version info mismatch'); } async function downloadAndVerifyRelease(updateInfo) { assert.strictEqual(typeof updateInfo, 'object'); await safe(shell.exec('cleanupOldArtifacts', `rm -rf ${path.join(os.tmpdir(), 'box-*')}`, { shell: '/bin/bash' }), { debug }); // remove any old artifacts await downloadUrl(updateInfo.boxVersionsUrl, `${paths.UPDATE_DIR}/versions.json`); await downloadUrl(updateInfo.boxVersionsSigUrl, `${paths.UPDATE_DIR}/versions.json.sig`); await gpgVerify(`${paths.UPDATE_DIR}/versions.json`, `${paths.UPDATE_DIR}/versions.json.sig`); await verifyUpdateInfo(`${paths.UPDATE_DIR}/versions.json`, updateInfo); await downloadUrl(updateInfo.sourceTarballUrl, `${paths.UPDATE_DIR}/box.tar.gz`); await downloadUrl(updateInfo.sourceTarballSigUrl, `${paths.UPDATE_DIR}/box.tar.gz.sig`); await gpgVerify(`${paths.UPDATE_DIR}/box.tar.gz`, `${paths.UPDATE_DIR}/box.tar.gz.sig`); const newBoxSource = path.join(os.tmpdir(), 'box-' + crypto.randomBytes(4).readUInt32LE(0)); const [mkdirError] = await safe(fs.promises.mkdir(newBoxSource, { recursive: true })); if (mkdirError) throw new BoxError(BoxError.FS_ERROR, `Failed to create directory ${newBoxSource}: ${mkdirError.message}`); await extractTarball(`${paths.UPDATE_DIR}/box.tar.gz`, newBoxSource); return { file: newBoxSource }; } async function checkFreeDiskSpace(neededSpace) { assert.strictEqual(typeof neededSpace, 'number'); // can probably be a bit more aggressive here since a new update can bring in new docker images const [error, diskUsage] = await safe(df.file('/')); if (error) throw new BoxError(BoxError.FS_ERROR, error); if (diskUsage.available < neededSpace) throw new BoxError(BoxError.FS_ERROR, `Not enough disk space. Updates require at least 2GB of free space. Available: ${df.prettyBytes(diskUsage.available)}`); } async function update(boxUpdateInfo, options, progressCallback) { assert(boxUpdateInfo && typeof boxUpdateInfo === 'object'); assert(options && typeof options === 'object'); assert.strictEqual(typeof progressCallback, 'function'); progressCallback({ percent: 1, message: 'Checking disk space' }); await checkFreeDiskSpace(2*1024*1024*1024); progressCallback({ percent: 5, message: 'Downloading and verifying release' }); const packageInfo = await downloadAndVerifyRelease(boxUpdateInfo); if (!options.skipBackup) { progressCallback({ percent: 10, message: 'Backing up' }); await backuptask.fullBackup({ preserveSecs: 3*7*24*60*60 }, (progress) => progressCallback({ percent: 10+progress.percent*70/100, message: progress.message })); await checkFreeDiskSpace(2*1024*1024*1024); // check again in case backup is in same disk } debug(`Updating box with ${boxUpdateInfo.sourceTarballUrl}`); progressCallback({ percent: 70, message: 'Installing update' }); await shell.promises.sudo('update', [ UPDATE_CMD, packageInfo.file, process.stdout.logFile ], {}); // run installer.sh from new box code as a separate service // Do not add any code here. The installer script will stop the box code any instant } async function checkUpdateRequirements(boxUpdateInfo) { assert.strictEqual(typeof boxUpdateInfo, 'object'); const result = await apps.list(); for (const app of result) { const maxBoxVersion = app.manifest.maxBoxVersion; if (semver.valid(maxBoxVersion) && semver.gt(boxUpdateInfo.version, maxBoxVersion)) { throw new BoxError(BoxError.BAD_STATE, `Cannot update to v${boxUpdateInfo.version} because ${app.fqdn} has a maxBoxVersion of ${maxBoxVersion}`); } } } async function updateToLatest(options, auditSource) { assert.strictEqual(typeof options, 'object'); assert.strictEqual(typeof auditSource, 'object'); const boxUpdateInfo = updateChecker.getUpdateInfo().box; if (!boxUpdateInfo) throw new BoxError(BoxError.NOT_FOUND, 'No update available'); if (!boxUpdateInfo.sourceTarballUrl) throw new BoxError(BoxError.BAD_STATE, 'No automatic update available'); if (semver.gte(constants.VERSION, boxUpdateInfo.version)) throw new BoxError(BoxError.NOT_FOUND, 'No update available'); // can happen after update completed or hotfix await checkUpdateRequirements(boxUpdateInfo); const error = locker.lock(locker.OP_BOX_UPDATE); if (error) throw new BoxError(BoxError.BAD_STATE, `Cannot update now: ${error.message}`); const backupConfig = await backups.getConfig(); const memoryLimit = backupConfig.limits?.memoryLimit ? Math.max(backupConfig.limits.memoryLimit/1024/1024, 400) : 400; const taskId = await tasks.add(tasks.TASK_UPDATE, [ boxUpdateInfo, options ]); await eventlog.add(eventlog.ACTION_UPDATE, auditSource, { taskId, boxUpdateInfo }); tasks.startTask(taskId, { timeout: 20 * 60 * 60 * 1000 /* 20 hours */, nice: 15, memoryLimit }, async (error) => { locker.unlock(locker.OP_BOX_UPDATE); debug('Update failed with error. %o', error); const timedOut = error.code === tasks.ETIMEOUT; await safe(eventlog.add(eventlog.ACTION_UPDATE_FINISH, auditSource, { taskId, errorMessage: error.message, timedOut })); }); return taskId; } async function notifyUpdate() { const version = safe.fs.readFileSync(paths.VERSION_FILE, 'utf8'); if (version === constants.VERSION) return; if (!version) { await eventlog.add(eventlog.ACTION_INSTALL_FINISH, AuditSource.CRON, { version: constants.VERSION }); } else { await eventlog.add(eventlog.ACTION_UPDATE_FINISH, AuditSource.CRON, { errorMessage: '', oldVersion: version || 'dev', newVersion: constants.VERSION }); const [error] = await safe(tasks.setCompletedByType(tasks.TASK_UPDATE, { error: null })); if (error && error.reason !== BoxError.NOT_FOUND) throw error; // when hotfixing, task may not exist } safe.fs.writeFileSync(paths.VERSION_FILE, constants.VERSION, 'utf8'); }