384 lines
17 KiB
JavaScript
384 lines
17 KiB
JavaScript
import apps from './apps.js';
|
|
import appstore from './appstore.js';
|
|
import assert from 'node:assert';
|
|
import AuditSource from './auditsource.js';
|
|
import BoxError from './boxerror.js';
|
|
import backupSites from './backupsites.js';
|
|
import backuptask from './backuptask.js';
|
|
import community from './community.js';
|
|
import constants from './constants.js';
|
|
import cron from './cron.js';
|
|
import { CronTime } from 'cron';
|
|
import crypto from 'node:crypto';
|
|
import debugModule from 'debug';
|
|
import df from './df.js';
|
|
import eventlog from './eventlog.js';
|
|
import fs from 'node:fs';
|
|
import locks from './locks.js';
|
|
import notifications from './notifications.js';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import paths from './paths.js';
|
|
import promiseRetry from './promise-retry.js';
|
|
import safe from 'safetydance';
|
|
import semver from 'semver';
|
|
import settings from './settings.js';
|
|
import shellModule from './shell.js';
|
|
import tasks from './tasks.js';
|
|
|
|
const debug = debugModule('box:updater');
|
|
const shell = shellModule('updater');
|
|
|
|
|
|
const RELEASES_PUBLIC_KEY = path.join(import.meta.dirname, 'releases.gpg');
|
|
const UPDATE_CMD = path.join(import.meta.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 is enabled to store automatic-update backups');
|
|
|
|
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 (!options.skipBackup && sites.length === 0) throw new BoxError(BoxError.BAD_STATE, 'No backup site is enabled to store automatic-update backups. Enable this in a backup site to continue.');
|
|
|
|
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, 800);
|
|
|
|
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 (updateError) => {
|
|
debug('Update failed with error. %o', updateError);
|
|
|
|
await locks.release(locks.TYPE_BOX_UPDATE_TASK);
|
|
await locks.releaseByTaskId(taskId);
|
|
|
|
const timedOut = updateError.code === tasks.ETIMEOUT;
|
|
await eventlog.add(eventlog.ACTION_UPDATE_FINISH, auditSource, { taskId, errorMessage: updateError.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) {
|
|
const updateInfo = await appstore.getAppUpdate(app, options);
|
|
await apps.update(app.id, { updateInfo });
|
|
return updateInfo;
|
|
} else if (app.versionsUrl) {
|
|
const updateInfo = await community.getAppUpdate(app, options);
|
|
await apps.update(app.id, { updateInfo });
|
|
return updateInfo;
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
export default {
|
|
setAutoupdatePattern,
|
|
getAutoupdatePattern,
|
|
|
|
startBoxUpdateTask,
|
|
updateBox,
|
|
|
|
autoUpdate,
|
|
|
|
notifyBoxUpdate,
|
|
|
|
checkForUpdates,
|
|
checkAppUpdate,
|
|
checkBoxUpdate,
|
|
|
|
getBoxUpdate,
|
|
};
|