diff --git a/CHANGES b/CHANGES index 823571f39..599f234a1 100644 --- a/CHANGES +++ b/CHANGES @@ -1330,3 +1330,5 @@ * Encryption support for incremental backups * Display restore errors in the UI * Update Haraka to 2.8.19 +* GPG verify releases + diff --git a/setup/start.sh b/setup/start.sh index cf4131f81..55828a07c 100755 --- a/setup/start.sh +++ b/setup/start.sh @@ -77,6 +77,7 @@ mkdir -p "${PLATFORM_DATA_DIR}/logrotate.d" mkdir -p "${PLATFORM_DATA_DIR}/acme" mkdir -p "${PLATFORM_DATA_DIR}/backup" mkdir -p "${PLATFORM_DATA_DIR}/logs/backup" +mkdir -p "${PLATFORM_DATA_DIR}/update" mkdir -p "${BOX_DATA_DIR}/appicons" mkdir -p "${BOX_DATA_DIR}/certs" @@ -238,7 +239,7 @@ fi echo "==> Changing ownership" chown "${USER}:${USER}" -R "${CONFIG_DIR}" -chown "${USER}:${USER}" -R "${PLATFORM_DATA_DIR}/nginx" "${PLATFORM_DATA_DIR}/collectd" "${PLATFORM_DATA_DIR}/addons" "${PLATFORM_DATA_DIR}/acme" "${PLATFORM_DATA_DIR}/backup" "${PLATFORM_DATA_DIR}/logs" +chown "${USER}:${USER}" -R "${PLATFORM_DATA_DIR}/nginx" "${PLATFORM_DATA_DIR}/collectd" "${PLATFORM_DATA_DIR}/addons" "${PLATFORM_DATA_DIR}/acme" "${PLATFORM_DATA_DIR}/backup" "${PLATFORM_DATA_DIR}/logs" "${PLATFORM_DATA_DIR}/update" chown "${USER}:${USER}" "${PLATFORM_DATA_DIR}/INFRA_VERSION" 2>/dev/null || true chown "${USER}:${USER}" "${PLATFORM_DATA_DIR}" diff --git a/src/paths.js b/src/paths.js index 052f15044..b2f1b9071 100644 --- a/src/paths.js +++ b/src/paths.js @@ -22,6 +22,7 @@ exports = module.exports = { NGINX_APPCONFIG_DIR: path.join(config.baseDir(), 'platformdata/nginx/applications'), NGINX_CERT_DIR: path.join(config.baseDir(), 'platformdata/nginx/cert'), BACKUP_INFO_DIR: path.join(config.baseDir(), 'platformdata/backup'), + UPDATE_DIR: path.join(config.baseDir(), 'platformdata/update'), SNAPSHOT_INFO_FILE: path.join(config.baseDir(), 'platformdata/backup/snapshot-info.json'), // this is not part of appdata because an icon may be set before install diff --git a/src/scripts/update.sh b/src/scripts/update.sh index 62dfa64e7..d11275501 100755 --- a/src/scripts/update.sh +++ b/src/scripts/update.sh @@ -9,7 +9,6 @@ fi readonly UPDATER_SERVICE="cloudron-updater" readonly DATA_FILE="/root/cloudron-update-data.json" -readonly curl="curl --fail --connect-timeout 20 --retry 10 --retry-delay 2 --max-time 300" if [[ $# == 1 && "$1" == "--check" ]]; then echo "OK" @@ -17,30 +16,16 @@ if [[ $# == 1 && "$1" == "--check" ]]; then fi if [[ $# != 2 ]]; then - echo "sourceTarballUrl and data arguments required" + echo "sourceDir and data arguments required" exit 1 fi -readonly sourceTarballUrl="${1}" +readonly source_dir="${1}" readonly data="${2}" -echo "Updating Cloudron with ${sourceTarballUrl}" +echo "Updating Cloudron with ${source_dir}" -# TODO: pre-download tarball -box_src_tmp_dir=$(mktemp -dt box-src-XXXXXX) -readonly installer_path="${box_src_tmp_dir}/scripts/installer.sh" -echo "Downloading box code from ${sourceTarballUrl} to ${box_src_tmp_dir}" - -for try in `seq 1 10`; do - if $curl -L "${sourceTarballUrl}" | tar -zxf - -C "${box_src_tmp_dir}"; then break; fi - echo "Failed to download source tarball, trying again" - sleep 5 -done - -if [[ ${try} -eq 10 ]]; then - echo "Release tarball download failed" - exit 3 -fi +readonly installer_path="${source_dir}/scripts/installer.sh" echo "=> reset service ${UPDATER_SERVICE} status in case it failed" if systemctl reset-failed "${UPDATER_SERVICE}"; then diff --git a/src/updater.js b/src/updater.js index 49bd36df6..718c06d1c 100644 --- a/src/updater.js +++ b/src/updater.js @@ -7,19 +7,27 @@ exports = module.exports = { }; var assert = require('assert'), + async = require('async'), + child_process = require('child_process'), backups = require('./backups.js'), caas = require('./caas.js'), config = require('./config.js'), + crypto = require('crypto'), debug = require('debug')('box:updater'), eventlog = require('./eventlog.js'), locker = require('./locker.js'), + mkdirp = require('mkdirp'), + os = require('os'), path = require('path'), + paths = require('./paths.js'), progress = require('./progress.js'), + safe = require('safetydance'), shell = require('./shell.js'), updateChecker = require('./updatechecker.js'), util = require('util'), _ = require('underscore'); +const RELEASES_PUBLIC_KEY = path.join(__dirname, 'releases.gpg'); const UPDATE_CMD = path.join(__dirname, 'scripts/update.sh'); function UpdaterError(reason, errorOrMessage) { @@ -47,6 +55,156 @@ UpdaterError.BAD_STATE = 'Bad state'; UpdaterError.ALREADY_UPTODATE = 'No Update Available'; UpdaterError.NOT_FOUND = 'Not found'; UpdaterError.SELF_UPGRADE_NOT_SUPPORTED = 'Self upgrade not supported'; +UpdaterError.NOT_SIGNED = 'Not signed'; + +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 UpdaterError(UpdaterError.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.exec('downloadUrl', '/usr/bin/curl', args.split(' '), { }, function (error) { + if (error) return retryCallback(new UpdaterError(UpdaterError.EXTERNAL_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) return callback(new UpdaterError(UpdaterError.NOT_SIGNED, `The signature in ${path.basename(sig)} could not verified`)); + + if (stdout.indexOf('[GNUPG:] VALIDSIG 0EADB19CDDA23CD0FE71E3470A372F8703C493CC')) return callback(); + + debug(`gpgVerify: verification of ${sig} failed: ${stdout}\n${stderr}`); + + return callback(new UpdaterError(UpdaterError.NOT_SIGNED, `The signature in ${path.basename(sig)} could not verified`)); + }); +} + +function extractTarball(tarball, dir, callback) { + const args = `-zxf ${tarball} -C ${dir}`; + + debug(`extractTarball: tar ${args}`); + + shell.exec('extractTarball', '/bin/tar', args.split(' '), { }, function (error) { + if (error) return callback(new UpdaterError(UpdaterError.EXTERNAL_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'); + + var releases = safe.JSON.parse(safe.fs.readFileSync(versionsFile, 'utf8')) || { }; + if (!releases[config.version()] || !releases[config.version()].next) return callback(new UpdaterError(UpdaterError.EXTERNAL_ERROR, 'No version info')); + var nextVersion = releases[config.version()].next; + if (typeof releases[nextVersion] !== 'object' || !releases[nextVersion]) return callback(new UpdaterError(UpdaterError.EXTERNAL_ERROR, 'No next version info')); + if (releases[nextVersion].sourceTarballUrl !== updateInfo.sourceTarballUrl) return callback(new UpdaterError(UpdaterError.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`), + mkdirp.bind(null, newBoxSource), + extractTarball.bind(null, `${paths.UPDATE_DIR}/box.tar.gz`, newBoxSource) + ], function (error) { + if (error) return callback(error); + + callback(null, { file: newBoxSource }); + }); +} + +function doUpdate(boxUpdateInfo, callback) { + assert(boxUpdateInfo && typeof boxUpdateInfo === 'object'); + + function updateError(e) { + progress.set(progress.UPDATE, -1, e.message); + callback(e); + } + + progress.set(progress.UPDATE, 5, 'Downloading and verifying release'); + + downloadAndVerifyRelease(boxUpdateInfo, function (error, packageInfo) { + if (error) return updateError(error); + + progress.set(progress.UPDATE, 10, 'Backing up'); + + backups.backupBoxAndApps({ userId: null, username: 'updater' }, function (error) { + if (error) return updateError(error); + + // NOTE: this data is opaque and will be passed through the installer.sh + var data= { + provider: config.provider(), + apiServerOrigin: config.apiServerOrigin(), + webServerOrigin: config.webServerOrigin(), + adminDomain: config.adminDomain(), + adminFqdn: config.adminFqdn(), + adminLocation: config.adminLocation(), + isDemo: config.isDemo(), + + appstore: { + apiServerOrigin: config.apiServerOrigin() + }, + caas: { + apiServerOrigin: config.apiServerOrigin(), + webServerOrigin: config.webServerOrigin() + }, + + version: boxUpdateInfo.version + }; + + debug('updating box %s %j', boxUpdateInfo.sourceTarballUrl, _.omit(data, 'tlsCert', 'tlsKey', 'token', 'appstore', 'caas')); + + progress.set(progress.UPDATE, 70, 'Installing update'); + + shell.sudo('update', [ UPDATE_CMD, packageInfo.file, JSON.stringify(data) ], function (error) { + if (error) return updateError(error); + + // Do not add any code here. The installer script will stop the box code any instant + }); + }); + }); +} function update(boxUpdateInfo, auditSource, callback) { assert.strictEqual(typeof boxUpdateInfo, 'object'); @@ -97,49 +255,3 @@ function updateToLatest(auditSource, callback) { update(boxUpdateInfo, auditSource, callback); } - -function doUpdate(boxUpdateInfo, callback) { - assert(boxUpdateInfo && typeof boxUpdateInfo === 'object'); - - function updateError(e) { - progress.set(progress.UPDATE, -1, e.message); - callback(e); - } - - progress.set(progress.UPDATE, 5, 'Backing up for update'); - - backups.backupBoxAndApps({ userId: null, username: 'updater' }, function (error) { - if (error) return updateError(error); - - // NOTE: this data is opaque and will be passed through the installer.sh - var data= { - provider: config.provider(), - apiServerOrigin: config.apiServerOrigin(), - webServerOrigin: config.webServerOrigin(), - adminDomain: config.adminDomain(), - adminFqdn: config.adminFqdn(), - adminLocation: config.adminLocation(), - isDemo: config.isDemo(), - - appstore: { - apiServerOrigin: config.apiServerOrigin() - }, - caas: { - apiServerOrigin: config.apiServerOrigin(), - webServerOrigin: config.webServerOrigin() - }, - - version: boxUpdateInfo.version - }; - - debug('updating box %s %j', boxUpdateInfo.sourceTarballUrl, _.omit(data, 'tlsCert', 'tlsKey', 'token', 'appstore', 'caas')); - - progress.set(progress.UPDATE, 5, 'Downloading and installing new version'); - - shell.sudo('update', [ UPDATE_CMD, boxUpdateInfo.sourceTarballUrl, JSON.stringify(data) ], function (error) { - if (error) return updateError(error); - - // Do not add any code here. The installer script will stop the box code any instant - }); - }); -}