'use strict'; exports = module.exports = { setAutoupdatePattern, getAutoupdatePattern, startBoxUpdateTask, updateBox, autoUpdate, notifyBoxUpdate, checkForUpdates, checkAppUpdate, checkBoxUpdate, getBoxUpdate, }; const apps = require('./apps.js'), appstore = require('./appstore.js'), assert = require('node:assert'), AuditSource = require('./auditsource.js'), BoxError = require('./boxerror.js'), backupSites = require('./backupsites.js'), backuptask = require('./backuptask.js'), constants = require('./constants.js'), cron = require('./cron.js'), { CronTime } = require('cron'), crypto = require('node:crypto'), debug = require('debug')('box:updater'), df = require('./df.js'), eventlog = require('./eventlog.js'), fs = require('node:fs'), locks = require('./locks.js'), notifications = require('./notifications.js'), os = require('node:os'), path = require('node:path'), paths = require('./paths.js'), promiseRetry = require('./promise-retry.js'), safe = require('safetydance'), semver = require('semver'), settings = require('./settings.js'), shell = require('./shell.js')('updater'), tasks = require('./tasks.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.CRON_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 downloadBoxUrl(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(`downloadBoxUrl: downloading ${url} to ${file}`); const [error] = await safe(shell.spawn('curl', ['-s', '--fail', url, '-o', file], { encoding: 'utf8' })); if (error) throw new BoxError(BoxError.NETWORK_ERROR, `Failed to download ${url}: ${error.message}`); debug('downloadBoxUrl: done'); }); } async function gpgVerifyBoxTarball(file, sig) { assert.strictEqual(typeof file, 'string'); assert.strictEqual(typeof sig, 'string'); const [error, stdout] = await safe(shell.spawn('/usr/bin/gpg', ['--status-fd', '1', '--no-default-keyring', '--keyring', RELEASES_PUBLIC_KEY, '--verify', sig, file], { encoding: 'utf8' })); if (error) { debug(`gpgVerifyBoxTarball: 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(`gpgVerifyBoxTarball: 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 extractBoxTarball(tarball, dir) { assert.strictEqual(typeof tarball, 'string'); assert.strictEqual(typeof dir, 'string'); debug(`extractBoxTarball: extracting ${tarball} to ${dir}`); const [error] = await safe(shell.spawn('tar', ['-zxf', tarball, '-C', dir], { encoding: 'utf8' })); if (error) throw new BoxError(BoxError.FS_ERROR, `Failed to extract release package: ${error.message}`); safe.fs.unlinkSync(tarball); debug('extractBoxTarball: extracted'); } async function verifyBoxUpdateInfo(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 downloadAndVerifyBoxRelease(updateInfo) { assert.strictEqual(typeof updateInfo, 'object'); const filenames = await fs.promises.readdir(os.tmpdir()); const oldArtifactNames = filenames.filter(f => f.startsWith('box-')); for (const artifactName of oldArtifactNames) { const fullPath = path.join(os.tmpdir(), artifactName); debug(`downloadAndVerifyBoxRelease: removing old artifact ${fullPath}`); await fs.promises.rm(fullPath, { recursive: true, force: true }); } await downloadBoxUrl(updateInfo.boxVersionsUrl, `${paths.UPDATE_DIR}/versions.json`); await downloadBoxUrl(updateInfo.boxVersionsSigUrl, `${paths.UPDATE_DIR}/versions.json.sig`); await gpgVerifyBoxTarball(`${paths.UPDATE_DIR}/versions.json`, `${paths.UPDATE_DIR}/versions.json.sig`); await verifyBoxUpdateInfo(`${paths.UPDATE_DIR}/versions.json`, updateInfo); await downloadBoxUrl(updateInfo.sourceTarballUrl, `${paths.UPDATE_DIR}/box.tar.gz`); await downloadBoxUrl(updateInfo.sourceTarballSigUrl, `${paths.UPDATE_DIR}/box.tar.gz.sig`); await gpgVerifyBoxTarball(`${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 extractBoxTarball(`${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 updateBox(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 downloadAndVerifyBoxRelease(boxUpdateInfo); if (!options.skipBackup) { progressCallback({ percent: 10, message: 'Backing up' }); const sites = await backupSites.listByContentForUpdates('box'); if (sites.length === 0) throw new BoxError(BoxError.BAD_STATE, 'no backup site for update'); for (const site of sites) { await backuptask.fullBackup(site.id, { 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 } await locks.wait(locks.TYPE_BOX_UPDATE); debug(`Updating box with ${boxUpdateInfo.sourceTarballUrl}`); progressCallback({ percent: 70, message: 'Installing update...' }); const [error] = await safe(shell.sudo([ UPDATE_CMD, packageInfo.file, process.stdout.logFile ], {})); // run installer.sh from new box code as a separate service if (error) await locks.release(locks.TYPE_BOX_UPDATE); // Do not add any code here. The installer script will stop the box code any instant } async function checkBoxUpdateRequirements(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 getBoxUpdate() { const updateInfo = safe.JSON.parse(safe.fs.readFileSync(paths.BOX_UPDATE_FILE, 'utf8')); return updateInfo || null; } async function startBoxUpdateTask(options, auditSource) { assert.strictEqual(typeof options, 'object'); assert.strictEqual(typeof auditSource, 'object'); const boxUpdateInfo = await getBoxUpdate(); 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 checkBoxUpdateRequirements(boxUpdateInfo); const sites = await backupSites.listByContentForUpdates('box'); if (sites.length === 0) throw new BoxError(BoxError.BAD_STATE, 'No backup site for update'); const [error] = await safe(locks.acquire(locks.TYPE_BOX_UPDATE_TASK)); if (error) throw new BoxError(BoxError.BAD_STATE, `Another update task is in progress: ${error.message}`); const memoryLimit = sites.reduce((acc, cur) => cur.limits?.memoryLimit ? Math.max(cur.limits.memoryLimit/1024/1024, acc) : acc, 400); const taskId = await tasks.add(tasks.TASK_BOX_UPDATE, [ boxUpdateInfo, options ]); await eventlog.add(eventlog.ACTION_UPDATE, auditSource, { taskId, boxUpdateInfo }); // background tasks.startTask(taskId, { timeout: 20 * 60 * 60 * 1000 /* 20 hours */, nice: 15, memoryLimit }) .then(() => debug('startBoxUpdateTask: update task completed')) .catch(async (error) => { debug('Update failed with error. %o', error); await locks.release(locks.TYPE_BOX_UPDATE_TASK); await locks.releaseByTaskId(taskId); const timedOut = error.code === tasks.ETIMEOUT; await eventlog.add(eventlog.ACTION_UPDATE_FINISH, auditSource, { taskId, errorMessage: error.message, timedOut }); }); return taskId; } async function notifyBoxUpdate() { const version = safe.fs.readFileSync(paths.VERSION_FILE, 'utf8'); if (version === constants.VERSION) return; safe.fs.unlinkSync(paths.BOX_UPDATE_FILE); if (!version) { await eventlog.add(eventlog.ACTION_INSTALL_FINISH, AuditSource.CRON, { version: constants.VERSION }); } else { debug(`notifyBoxUpdate: update finished from ${version} to ${constants.VERSION}`); const [error] = await safe(tasks.setCompletedByType(tasks.TASK_BOX_UPDATE, { error: null })); if (error && error.reason !== BoxError.NOT_FOUND) throw error; // when hotfixing, task may not exist await eventlog.add(eventlog.ACTION_UPDATE_FINISH, AuditSource.CRON, { errorMessage: '', oldVersion: version || 'dev', newVersion: constants.VERSION }); await notifications.unpin(notifications.TYPE_BOX_UPDATE, { context: constants.VERSION }); } safe.fs.writeFileSync(paths.VERSION_FILE, constants.VERSION, 'utf8'); } async function autoUpdate(auditSource) { assert.strictEqual(typeof auditSource, 'object'); const boxUpdateInfo = await getBoxUpdate(); // do box before app updates. for the off chance that the box logic fixes some app update logic issue if (boxUpdateInfo && !boxUpdateInfo.unstable) { debug('autoUpdate: starting box autoupdate to %j', boxUpdateInfo.version); const [error] = await safe(startBoxUpdateTask({ skipBackup: false }, AuditSource.CRON)); if (!error) return; // do not start app updates when a box update got scheduled debug(`autoUpdate: failed to start box autoupdate task: ${error.message}`); // fall through to update apps if box update never started (failed ubuntu or avx check) } const result = await apps.list(); for (const app of result) { if (!app.updateInfo) continue; if (!app.updateInfo.isAutoUpdatable) { debug(`autoUpdate: ${app.fqdn} requires manual update. skipping`); continue; } const sites = await backupSites.listByContentForUpdates(app.id); if (sites.length === 0) { debug(`autoUpdate: ${app.fqdn} has no backup site for updates. skipping`); continue; } const data = { manifest: app.updateInfo.manifest, force: false }; debug(`autoUpdate: ${app.fqdn} will be automatically updated`); const [updateError] = await safe(apps.updateApp(app, data, auditSource)); if (updateError) debug(`autoUpdate: error autoupdating ${app.fqdn}: ${updateError.message}`); } } async function checkAppUpdate(app, options) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof options, 'object'); if (app.appStoreId === '') return null; // appStoreId can be '' for dev apps const updateInfo = await appstore.getAppUpdate(app, options); await apps.update(app.id, { updateInfo }); return updateInfo; } async function checkBoxUpdate(options) { assert.strictEqual(typeof options, 'object'); debug('checkBoxUpdate: checking for updates'); const updateInfo = await appstore.getBoxUpdate(options); if (updateInfo) { safe.fs.writeFileSync(paths.BOX_UPDATE_FILE, JSON.stringify(updateInfo, null, 4)); } else { safe.fs.unlinkSync(paths.BOX_UPDATE_FILE); } } async function raiseNotifications() { const pattern = await getAutoupdatePattern(); const boxUpdate = await getBoxUpdate(); if (pattern === constants.CRON_PATTERN_NEVER && boxUpdate) { const changelog = boxUpdate.changelog.map((m) => `* ${m}\n`).join(''); const message = `Changelog:\n${changelog}\n\nGo to the Settings view to update.\n\n`; await notifications.pin(notifications.TYPE_BOX_UPDATE, `Cloudron v${boxUpdate.version} is available`, message, { context: boxUpdate.version }); } const result = await apps.list(); for (const app of result) { // currently, we do not raise notifications when auto-update is disabled. separate notifications appears spammy when having many apps // in the future, we can maybe aggregate? if (app.updateInfo && !app.updateInfo.isAutoUpdatable) { debug(`autoUpdate: ${app.fqdn} cannot be autoupdated. skipping`); await notifications.pin(notifications.TYPE_MANUAL_APP_UPDATE_NEEDED, `${app.manifest.title} at ${app.fqdn} requires manual update to version ${app.updateInfo.manifest.version}`, `Changelog:\n${app.updateInfo.manifest.changelog}\n`, { context: app.id }); continue; } } } async function checkForUpdates(options) { assert.strictEqual(typeof options, 'object'); const [boxError] = await safe(checkBoxUpdate(options)); if (boxError) debug('checkForUpdates: error checking for box updates: %o', boxError); // check app updates const result = await apps.list(); for (const app of result) { await safe(checkAppUpdate(app, options), { debug }); } // raise notifications here because the updatechecker runs regardless of auto-updater cron job await raiseNotifications(); }