'use strict'; exports = module.exports = { updateToLatest, update }; var apps = require('./apps.js'), assert = require('assert'), async = require('async'), BoxError = require('./boxerror.js'), child_process = require('child_process'), backups = require('./backups.js'), constants = require('./constants.js'), crypto = require('crypto'), debug = require('debug')('box:updater'), df = require('@sindresorhus/df'), eventlog = require('./eventlog.js'), fs = require('fs'), locker = require('./locker.js'), os = require('os'), path = require('path'), paths = require('./paths.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'); function downloadUrl(url, file, callback) { assert.strictEqual(typeof file, 'string'); assert.strictEqual(typeof callback, 'function'); // do not assert since it comes from the appstore if (typeof url !== 'string') return callback(new BoxError(BoxError.EXTERNAL_ERROR, `url cannot be download to ${file} as it is not a string`)); let retryCount = 0; safe.fs.unlinkSync(file); async.retry({ times: 10, interval: 5000 }, function (retryCallback) { debug(`Downloading ${url} to ${file}. Try ${++retryCount}`); const args = `-s --fail ${url} -o ${file}`; debug(`downloadUrl: curl ${args}`); shell.spawn('downloadUrl', '/usr/bin/curl', args.split(' '), {}, function (error) { if (error) return retryCallback(new BoxError(BoxError.NETWORK_ERROR, `Failed to download ${url}: ${error.message}`)); debug(`downloadUrl: downloadUrl ${url} to ${file}`); retryCallback(); }); }, callback); } function gpgVerify(file, sig, callback) { const cmd = `/usr/bin/gpg --status-fd 1 --no-default-keyring --keyring ${RELEASES_PUBLIC_KEY} --verify ${sig} ${file}`; debug(`gpgVerify: ${cmd}`); child_process.exec(cmd, { encoding: 'utf8' }, function (error, stdout, stderr) { if (error) { debug(`gpgVerify: command failed. error: ${error}\n stdout: ${stdout}\n stderr: ${stderr}`); return callback(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 callback(); debug(`gpgVerify: verification of ${sig} failed: ${stdout}\n${stderr}`); return callback(new BoxError(BoxError.NOT_SIGNED, `The signature in ${path.basename(sig)} could not be verified (bad sig)`)); }); } function extractTarball(tarball, dir, callback) { const args = `-zxf ${tarball} -C ${dir}`; debug(`extractTarball: tar ${args}`); shell.spawn('extractTarball', '/bin/tar', args.split(' '), {}, function (error) { if (error) return callback(new BoxError(BoxError.FS_ERROR, `Failed to extract release package: ${error.message}`)); safe.fs.unlinkSync(tarball); debug(`extractTarball: extracted ${tarball} to ${dir}`); callback(); }); } function verifyUpdateInfo(versionsFile, updateInfo, callback) { assert.strictEqual(typeof versionsFile, 'string'); assert.strictEqual(typeof updateInfo, 'object'); assert.strictEqual(typeof callback, 'function'); const releases = safe.JSON.parse(safe.fs.readFileSync(versionsFile, 'utf8')) || {}; if (!releases[constants.VERSION]) return callback(new BoxError(BoxError.EXTERNAL_ERROR, `No version info for ${constants.VERSION}`)); if (!releases[constants.VERSION].next) return callback(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]) return callback(new BoxError(BoxError.EXTERNAL_ERROR, 'No next version info')); if (releases[nextVersion].sourceTarballUrl !== updateInfo.sourceTarballUrl) return callback(new BoxError(BoxError.EXTERNAL_ERROR, 'Version info mismatch')); callback(); } function downloadAndVerifyRelease(updateInfo, callback) { assert.strictEqual(typeof updateInfo, 'object'); assert.strictEqual(typeof callback, 'function'); let newBoxSource = path.join(os.tmpdir(), 'box-' + crypto.randomBytes(4).readUInt32LE(0)); async.series([ downloadUrl.bind(null, updateInfo.boxVersionsUrl, `${paths.UPDATE_DIR}/versions.json`), downloadUrl.bind(null, updateInfo.boxVersionsSigUrl, `${paths.UPDATE_DIR}/versions.json.sig`), gpgVerify.bind(null, `${paths.UPDATE_DIR}/versions.json`, `${paths.UPDATE_DIR}/versions.json.sig`), verifyUpdateInfo.bind(null, `${paths.UPDATE_DIR}/versions.json`, updateInfo), downloadUrl.bind(null, updateInfo.sourceTarballUrl, `${paths.UPDATE_DIR}/box.tar.gz`), downloadUrl.bind(null, updateInfo.sourceTarballSigUrl, `${paths.UPDATE_DIR}/box.tar.gz.sig`), gpgVerify.bind(null, `${paths.UPDATE_DIR}/box.tar.gz`, `${paths.UPDATE_DIR}/box.tar.gz.sig`), fs.mkdir.bind(null, newBoxSource, { recursive: true }), extractTarball.bind(null, `${paths.UPDATE_DIR}/box.tar.gz`, newBoxSource) ], function (error) { if (error) return callback(error); callback(null, { file: newBoxSource }); }); } function checkFreeDiskSpace(neededSpace, callback) { assert.strictEqual(typeof neededSpace, 'number'); assert.strictEqual(typeof callback, 'function'); // can probably be a bit more aggressive here since a new update can bring in new docker images df.file('/').then(function (diskUsage) { if (diskUsage.available < neededSpace) return callback(new BoxError(BoxError.FS_ERROR, 'Not enough disk space')); callback(null); }).catch(function (error) { callback(new BoxError(BoxError.FS_ERROR, error)); }); } function update(boxUpdateInfo, options, progressCallback, callback) { assert(boxUpdateInfo && typeof boxUpdateInfo === 'object'); assert(options && typeof options === 'object'); assert.strictEqual(typeof progressCallback, 'function'); assert.strictEqual(typeof callback, 'function'); progressCallback({ percent: 1, message: 'Checking disk space' }); checkFreeDiskSpace(1024*1024*1024, function (error) { if (error) return callback(error); progressCallback({ percent: 5, message: 'Downloading and verifying release' }); downloadAndVerifyRelease(boxUpdateInfo, function (error, packageInfo) { if (error) return callback(error); function maybeBackup(next) { if (options.skipBackup) return next(); progressCallback({ percent: 10, message: 'Backing up' }); backups.backupBoxAndApps({ preserveSecs: 3*7*24*60*60 }, (progress) => progressCallback({ percent: 10+progress.percent*70/100, message: progress.message }), next); } maybeBackup(function (error) { if (error) return callback(error); debug('updating box %s', boxUpdateInfo.sourceTarballUrl); progressCallback({ percent: 70, message: 'Installing update' }); // run installer.sh from new box code as a separate service shell.sudo('update', [ UPDATE_CMD, packageInfo.file ], {}, function (error) { if (error) return callback(error); // Do not add any code here. The installer script will stop the box code any instant }); }); }); }); } function canUpdate(boxUpdateInfo, callback) { assert.strictEqual(typeof boxUpdateInfo, 'object'); assert.strictEqual(typeof callback, 'function'); apps.getAll(function (error, result) { if (error) return callback(error); for (let app of result) { const maxBoxVersion = app.manifest.maxBoxVersion; if (semver.valid(maxBoxVersion) && semver.gt(boxUpdateInfo.version, maxBoxVersion)) { return callback(new BoxError(BoxError.BAD_STATE, `Cannot update to v${boxUpdateInfo.version} because ${app.fqdn} has a maxBoxVersion of ${maxBoxVersion}`)); } } callback(); }); } function updateToLatest(options, auditSource, callback) { assert.strictEqual(typeof options, 'object'); assert.strictEqual(typeof auditSource, 'object'); assert.strictEqual(typeof callback, 'function'); var boxUpdateInfo = updateChecker.getUpdateInfo().box; if (!boxUpdateInfo) return callback(new BoxError(BoxError.NOT_FOUND, 'No update available')); if (!boxUpdateInfo.sourceTarballUrl) return callback(new BoxError(BoxError.BAD_STATE, 'No automatic update available')); canUpdate(boxUpdateInfo, function (error) { if (error) return callback(error); error = locker.lock(locker.OP_BOX_UPDATE); if (error) return callback(new BoxError(BoxError.BAD_STATE, `Cannot update now: ${error.message}`)); settings.getBackupConfig(function (error, backupConfig) { if (error) return callback(error); const memoryLimit = 'memoryLimit' in backupConfig ? Math.max(backupConfig.memoryLimit/1024/1024, 400) : 400; tasks.add(tasks.TASK_UPDATE, [ boxUpdateInfo, options ], function (error, taskId) { if (error) return callback(error); eventlog.add(eventlog.ACTION_UPDATE, auditSource, { taskId, boxUpdateInfo }); tasks.startTask(taskId, { timeout: 20 * 60 * 60 * 1000 /* 20 hours */, nice: 15, memoryLimit }, (error) => { locker.unlock(locker.OP_BOX_UPDATE); debug('Update failed with error', error); const timedOut = error.code === tasks.ETIMEOUT; eventlog.add(eventlog.ACTION_UPDATE_FINISH, auditSource, { taskId, errorMessage: error.message, timedOut }); }); callback(null, taskId); }); }); }); }