diff --git a/dashboard/src/js/client.js b/dashboard/src/js/client.js index 9eae61391..f8ffbe8aa 100644 --- a/dashboard/src/js/client.js +++ b/dashboard/src/js/client.js @@ -2001,7 +2001,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout }; Client.prototype.isRebootRequired = function (callback) { - get('/api/v1/cloudron/reboot', null, function (error, data, status) { + get('/api/v1/system/reboot', null, function (error, data, status) { if (error) return callback(error); if (status !== 200) return callback(new ClientError(status, data)); @@ -2010,7 +2010,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout }; Client.prototype.reboot = function (callback) { - post('/api/v1/cloudron/reboot', {}, null, function (error, data, status) { + post('/api/v1/system/reboot', {}, null, function (error, data, status) { if (error) return callback(error); if (status !== 202) return callback(new ClientError(status, data)); @@ -2033,7 +2033,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout }; Client.prototype.getBlockDevices = function (callback) { - get('/api/v1/cloudron/block_devices', null, function (error, data, status) { + get('/api/v1/system/block_devices', null, function (error, data, status) { if (error) return callback(error); if (status !== 200) return callback(new ClientError(status, data)); @@ -2042,7 +2042,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout }; Client.prototype.disks = function (callback) { - get('/api/v1/cloudron/disks', null, function (error, data, status) { + get('/api/v1/system/disks', null, function (error, data, status) { if (error) return callback(error); if (status !== 200) return callback(new ClientError(status, data)); @@ -2051,7 +2051,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout }; Client.prototype.diskUsage = function (callback) { - get('/api/v1/cloudron/disk_usage', null, function (error, data, status) { + get('/api/v1/system/disk_usage', null, function (error, data, status) { if (error) return callback(error); if (status !== 200) return callback(new ClientError(status, data)); @@ -2060,7 +2060,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout }; Client.prototype.refreshDiskUsage = function (callback) { - post('/api/v1/cloudron/disk_usage', {}, null, function (error, data, status) { + post('/api/v1/system/disk_usage', {}, null, function (error, data, status) { if (error) return callback(error); if (status !== 201) return callback(new ClientError(status, data)); @@ -2069,7 +2069,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout }; Client.prototype.memory = function (callback) { - get('/api/v1/cloudron/memory', null, function (error, data, status) { + get('/api/v1/system/memory', null, function (error, data, status) { if (error) return callback(error); if (status !== 200) return callback(new ClientError(status, data)); @@ -2087,7 +2087,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout }; Client.prototype.getSystemGraphs = function (fromMinutes, callback) { - get('/api/v1/cloudron/graphs', { params: { fromMinutes: fromMinutes } }, function (error, data, status) { + get('/api/v1/system/graphs', { params: { fromMinutes: fromMinutes } }, function (error, data, status) { if (error) return callback(error); if (status !== 200) return callback(new ClientError(status, data)); diff --git a/frontend/src/models/LogsModel.js b/frontend/src/models/LogsModel.js index ecb358de4..6e9aea946 100644 --- a/frontend/src/models/LogsModel.js +++ b/frontend/src/models/LogsModel.js @@ -36,11 +36,11 @@ export function create(origin, accessToken, type, id) { let downloadApi = ''; if (type === 'platform') { - streamApi = '/api/v1/cloudron/logstream/box'; - downloadApi = '/api/v1/cloudron/logs/box'; + streamApi = '/api/v1/system/logstream/box'; + downloadApi = '/api/v1/system/logs/box'; } else if (type === 'crash') { - streamApi = `/api/v1/cloudron/logstream/crash-${id}`; - downloadApi = `/api/v1/cloudron/logs/crash-${id}`; + streamApi = `/api/v1/system/logstream/crash-${id}`; + downloadApi = `/api/v1/system/logs/crash-${id}`; } else if (type === 'app') { streamApi = `/api/v1/apps/${id}/logstream`; downloadApi = `/api/v1/apps/${id}/logs`; diff --git a/src/cloudron.js b/src/cloudron.js index d9387cb70..47ce00c87 100644 --- a/src/cloudron.js +++ b/src/cloudron.js @@ -4,10 +4,6 @@ exports = module.exports = { initialize, uninitialize, getConfig, - getLogs, - - reboot, - isRebootRequired, onActivated, @@ -17,12 +13,6 @@ exports = module.exports = { setDashboardDomain, updateDashboardDomain, - updateDiskUsage, - - runSystemChecks, - - getBlockDevices, - getTimeZone, setTimeZone, @@ -42,28 +32,20 @@ const apps = require('./apps.js'), dns = require('./dns.js'), dockerProxy = require('./dockerproxy.js'), eventlog = require('./eventlog.js'), - fs = require('fs'), - logs = require('./logs.js'), - mail = require('./mail.js'), moment = require('moment-timezone'), network = require('./network.js'), - notifications = require('./notifications.js'), oidc = require('./oidc.js'), - path = require('path'), paths = require('./paths.js'), platform = require('./platform.js'), reverseProxy = require('./reverseproxy.js'), safe = require('safetydance'), services = require('./services.js'), settings = require('./settings.js'), - shell = require('./shell.js'), tasks = require('./tasks.js'), timers = require('timers/promises'), translation = require('./translation.js'), users = require('./users.js'); -const REBOOT_CMD = path.join(__dirname, 'scripts/reboot.sh'); - async function initialize() { safe(runStartupTasks(), { debug }); // background @@ -171,75 +153,6 @@ async function getConfig() { }; } -async function reboot() { - await notifications.clearAlert(notifications.ALERT_REBOOT, 'Reboot Required'); - - const [error] = await safe(shell.promises.sudo('reboot', [ REBOOT_CMD ], {})); - if (error) debug('reboot: could not reboot. %o', error); -} - -async function isRebootRequired() { - // https://serverfault.com/questions/92932/how-does-ubuntu-keep-track-of-the-system-restart-required-flag-in-motd - return fs.existsSync('/var/run/reboot-required'); -} - -async function runSystemChecks() { - debug('runSystemChecks: checking status'); - - const checks = [ - checkMailStatus(), - checkRebootRequired(), - checkUbuntuVersion() - ]; - - await Promise.allSettled(checks); -} - -async function checkMailStatus() { - const result = await mail.checkConfiguration(); - if (result.status) { - await notifications.clearAlert(notifications.ALERT_MAIL_STATUS, 'Email is not configured properly'); - } else { - await notifications.alert(notifications.ALERT_MAIL_STATUS, 'Email is not configured properly', result.message, { persist: true }); - } -} - -async function checkRebootRequired() { - const rebootRequired = await isRebootRequired(); - if (rebootRequired) { - await notifications.alert(notifications.ALERT_REBOOT, 'Reboot Required', 'To finish ubuntu security updates, a reboot is necessary.', { persist: true }); - } else { - await notifications.clearAlert(notifications.ALERT_REBOOT, 'Reboot Required'); - } -} - -async function checkUbuntuVersion() { - const isXenial = fs.readFileSync('/etc/lsb-release', 'utf-8').includes('16.04'); - if (!isXenial) return; - - await notifications.alert(notifications.ALERT_UPDATE_UBUNTU, 'Ubuntu upgrade required', 'Ubuntu 16.04 has reached end of life and will not receive security and maintenance updates. Please follow https://docs.cloudron.io/guides/upgrade-ubuntu-18/ to upgrade to Ubuntu 18 at the earliest.', { persist: true }); -} - -async function getLogs(unit, options) { - assert.strictEqual(typeof unit, 'string'); - assert(options && typeof options === 'object'); - - debug(`Getting logs for ${unit}`); - - let logFile = ''; - if (unit === 'box') logFile = path.join(paths.LOG_DIR, 'box.log'); // box.log is at the top - else throw new BoxError(BoxError.BAD_FIELD, `No such unit '${unit}'`); - - const cp = logs.tail([logFile], { lines: options.lines, follow: options.follow }); - - const logStream = new logs.LogStream({ format: options.format || 'json', source: unit }); - logStream.close = cp.kill.bind(cp, 'SIGKILL'); // hook for caller. closing stream kills the child process - - cp.stdout.pipe(logStream); - - return logStream; -} - async function prepareDashboardDomain(domain, auditSource) { assert.strictEqual(typeof domain, 'string'); assert.strictEqual(typeof auditSource, 'object'); @@ -320,30 +233,6 @@ async function setupDnsAndCert(subdomain, domain, auditSource, progressCallback) await reverseProxy.ensureCertificate(location, {}, auditSource); } -async function updateDiskUsage() { - const taskId = await tasks.add(tasks.TASK_UPDATE_DISK_USAGE, []); - tasks.startTask(taskId, {}); - return taskId; -} - -async function getBlockDevices() { - const info = safe.JSON.parse(safe.child_process.execSync('lsblk --paths --json --list --fs', { encoding: 'utf8' })); - if (!info) throw new BoxError(BoxError.INTERNAL_ERROR, safe.error.message); - - const devices = info.blockdevices.filter(d => d.fstype === 'ext4' || d.fstype === 'xfs'); - - debug(`getBlockDevices: Found ${devices.length} devices. ${devices.map(d => d.name).join(', ')}`); - - return devices.map(function (d) { - return { - path: d.name, - size: d.fsavail || 0, - type: d.fstype, - mountpoint: d.mountpoints ? d.mountpoints[0] : d.mountpoint // we only support one mountpoint here old lsblk only exposed one via .mountpoint - }; - }); -} - async function getTimeZone() { const tz = await settings.get(settings.TIME_ZONE_KEY); return tz || 'UTC'; diff --git a/src/cron.js b/src/cron.js index de2f2293f..427c6b393 100644 --- a/src/cron.js +++ b/src/cron.js @@ -31,6 +31,7 @@ const appHealthMonitor = require('./apphealthmonitor.js'), dyndns = require('./dyndns.js'), eventlog = require('./eventlog.js'), janitor = require('./janitor.js'), + mail = require('./mail.js'), network = require('./network.js'), paths = require('./paths.js'), safe = require('safetydance'), @@ -45,6 +46,7 @@ const gJobs = { backup: null, updateChecker: null, systemChecks: null, + mailStatusCheck: null, diskSpaceChecker: null, certificateRenew: null, cleanupBackups: null, @@ -95,13 +97,19 @@ async function startJobs() { gJobs.systemChecks = new CronJob({ cronTime: `00 ${minute} 2 * * *`, // once a day. if you change this interval, change the notification messages with correct duration - onTick: async () => await safe(cloudron.runSystemChecks(), { debug }), + onTick: async () => await safe(system.runSystemChecks(), { debug }), + start: true + }); + + gJobs.mailStatusCheck = new CronJob({ + cronTime: `00 ${minute} 2 * * *`, // once a day. if you change this interval, change the notification messages with correct duration + onTick: async () => await safe(mail.checkStatus(), { debug }), start: true }); gJobs.diskUsage = new CronJob({ cronTime: `00 ${minute} 3 * * *`, // once a day - onTick: async () => await safe(cloudron.updateDiskUsage(), { debug }), + onTick: async () => await safe(system.updateDiskUsage(), { debug }), start: true }); diff --git a/src/mail.js b/src/mail.js index 148714440..6989ca528 100644 --- a/src/mail.js +++ b/src/mail.js @@ -57,6 +57,8 @@ exports = module.exports = { delList, resolveList, + checkStatus, + OWNERTYPE_USER: 'user', OWNERTYPE_GROUP: 'group', OWNERTYPE_APP: 'app', @@ -90,6 +92,7 @@ const assert = require('assert'), net = require('net'), network = require('./network.js'), nodemailer = require('nodemailer'), + notifications = require('./notifications.js'), os = require('os'), path = require('path'), paths = require('./paths.js'), @@ -1503,3 +1506,12 @@ async function resolveList(listName, listDomain) { return { resolvedMembers, list }; } + +async function checkStatus() { + const result = await checkConfiguration(); + if (result.status) { + await notifications.clearAlert(notifications.ALERT_MAIL_STATUS, 'Email is not configured properly'); + } else { + await notifications.alert(notifications.ALERT_MAIL_STATUS, 'Email is not configured properly', result.message, { persist: true }); + } +} diff --git a/src/routes/cloudron.js b/src/routes/cloudron.js index 78122a96b..f5cd7db77 100644 --- a/src/routes/cloudron.js +++ b/src/routes/cloudron.js @@ -6,24 +6,17 @@ exports = module.exports = { passwordResetRequest, passwordReset, setupAccount, - reboot, - isRebootRequired, getConfig, - getDisks, - getDiskUsage, - updateDiskUsage, - getMemory, - getLogs, - getLogStream, + updateDashboardDomain, prepareDashboardDomain, - getLanguages, - getSystemGraphs, - getPlatformStatus, - getBlockDevices, + getPlatformStatus, + + getLanguages, getLanguage, setLanguage, + getTimeZone, setTimeZone }; @@ -35,13 +28,11 @@ const assert = require('assert'), constants = require('../constants.js'), debug = require('debug')('box:routes/cloudron'), eventlog = require('../eventlog.js'), - graphs = require('../graphs.js'), HttpError = require('connect-lastmile').HttpError, HttpSuccess = require('connect-lastmile').HttpSuccess, platform = require('../platform.js'), safe = require('safetydance'), speakeasy = require('speakeasy'), - system = require('../system.js'), tokens = require('../tokens.js'), translation = require('../translation.js'), users = require('../users.js'); @@ -141,20 +132,6 @@ async function setupAccount(req, res, next) { next(new HttpSuccess(201, { accessToken })); } -async function reboot(req, res, next) { - // Finish the request, to let the appstore know we triggered the reboot - next(new HttpSuccess(202, {})); - - await safe(cloudron.reboot()); -} - -async function isRebootRequired(req, res, next) { - const [error, rebootRequired] = await safe(cloudron.isRebootRequired()); - if (error) return next(BoxError.toHttpError(error)); - - next(new HttpSuccess(200, { rebootRequired })); -} - async function getConfig(req, res, next) { const [error, cloudronConfig] = await safe(cloudron.getConfig()); if (error) return next(BoxError.toHttpError(error)); @@ -162,97 +139,6 @@ async function getConfig(req, res, next) { next(new HttpSuccess(200, cloudronConfig)); } -async function getDisks(req, res, next) { - const [getDisksError, disks] = await safe(system.getDisks()); - if (getDisksError) return next(BoxError.toHttpError(getDisksError)); - - let [getSwapsError, swaps] = await safe(system.getSwaps()); - if (getSwapsError) return next(BoxError.toHttpError(getSwapsError)); - - next(new HttpSuccess(200, { disks, swaps })); -} - -async function getDiskUsage(req, res, next) { - const [error, result] = await safe(system.getDiskUsage()); - if (error) return next(BoxError.toHttpError(error)); - - next(new HttpSuccess(200, { usage: result })); -} - -async function updateDiskUsage(req, res, next) { - const [error, taskId] = await safe(cloudron.updateDiskUsage()); - if (error) return next(BoxError.toHttpError(error)); - - next(new HttpSuccess(201, { taskId })); -} - -async function getMemory(req, res, next) { - const [error, result] = await safe(system.getMemory()); - if (error) return next(BoxError.toHttpError(error)); - - next(new HttpSuccess(200, result)); -} - -async function getLogs(req, res, next) { - assert.strictEqual(typeof req.params.unit, 'string'); - - const lines = 'lines' in req.query ? parseInt(req.query.lines, 10) : 10; // we ignore last-event-id - if (isNaN(lines)) return next(new HttpError(400, 'lines must be a number')); - - const options = { - lines: lines, - follow: false, - format: req.query.format || 'json' - }; - - const [error, logStream] = await safe(cloudron.getLogs(req.params.unit, options)); - if (error) return next(BoxError.toHttpError(error)); - - res.writeHead(200, { - 'Content-Type': 'application/x-logs', - 'Content-Disposition': `attachment; filename="${req.params.unit}.log"`, - 'Cache-Control': 'no-cache', - 'X-Accel-Buffering': 'no' // disable nginx buffering - }); - logStream.pipe(res); -} - -async function getLogStream(req, res, next) { - assert.strictEqual(typeof req.params.unit, 'string'); - - const lines = 'lines' in req.query ? parseInt(req.query.lines, 10) : 10; // we ignore last-event-id - if (isNaN(lines)) return next(new HttpError(400, 'lines must be a valid number')); - - function sse(id, data) { return 'id: ' + id + '\ndata: ' + data + '\n\n'; } - - if (req.headers.accept !== 'text/event-stream') return next(new HttpError(400, 'This API call requires EventStream')); - - const options = { - lines: lines, - follow: true, - format: req.query.format || 'json' - }; - - const [error, logStream] = await safe(cloudron.getLogs(req.params.unit, options)); - if (error) return next(BoxError.toHttpError(error)); - - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - 'X-Accel-Buffering': 'no', // disable nginx buffering - 'Access-Control-Allow-Origin': '*' - }); - res.write('retry: 3000\n'); - res.on('close', logStream.close); - logStream.on('data', function (data) { - const obj = JSON.parse(data); - res.write(sse(obj.realtimeTimestamp, JSON.stringify(obj))); // send timestamp as id - }); - logStream.on('end', res.end.bind(res)); - logStream.on('error', res.end.bind(res, null)); -} - async function updateDashboardDomain(req, res, next) { if (!req.body.domain || typeof req.body.domain !== 'string') return next(new HttpError(400, 'domain must be a string')); @@ -278,28 +164,10 @@ async function getLanguages(req, res, next) { next(new HttpSuccess(200, { languages })); } -async function getSystemGraphs(req, res, next) { - if (!req.query.fromMinutes || !parseInt(req.query.fromMinutes)) return next(new HttpError(400, 'fromMinutes must be a number')); - - const fromMinutes = parseInt(req.query.fromMinutes); - const noNullPoints = !!req.query.noNullPoints; - const [error, result] = await safe(graphs.getSystem(fromMinutes, noNullPoints)); - if (error) return next(new HttpError(500, error)); - - next(new HttpSuccess(200, result)); -} - async function getPlatformStatus(req, res, next) { next(new HttpSuccess(200, platform.getStatus())); } -async function getBlockDevices(req, res, next) { - const [error, devices] = await safe(cloudron.getBlockDevices()); - if (error) return next(new HttpError(500, error)); - - next(new HttpSuccess(200, { devices })); -} - async function getTimeZone(req, res, next) { const [error, timeZone] = await safe(cloudron.getTimeZone()); if (error) return next(BoxError.toHttpError(error)); diff --git a/src/routes/index.js b/src/routes/index.js index a43ac6655..342bc5c59 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -22,10 +22,11 @@ exports = module.exports = { oidc: require('./oidc.js'), profile: require('./profile.js'), provision: require('./provision.js'), - reverseProxy: require('./reverseProxy.js'), + reverseProxy: require('./reverseproxy.js'), services: require('./services.js'), settings: require('./settings.js'), support: require('./support.js'), + system: require('./system.js'), tasks: require('./tasks.js'), tokens: require('./tokens.js'), updater: require('./updater.js'), diff --git a/src/routes/system.js b/src/routes/system.js new file mode 100644 index 000000000..10fa76b71 --- /dev/null +++ b/src/routes/system.js @@ -0,0 +1,145 @@ +'use strict'; + +exports = module.exports = { + reboot, + isRebootRequired, + getDisks, + getDiskUsage, + updateDiskUsage, + getMemory, + getLogs, + getLogStream, + getSystemGraphs, + getBlockDevices, +}; + +const assert = require('assert'), + BoxError = require('../boxerror.js'), + graphs = require('../graphs.js'), + HttpError = require('connect-lastmile').HttpError, + HttpSuccess = require('connect-lastmile').HttpSuccess, + safe = require('safetydance'), + system = require('../system.js'); + +async function reboot(req, res, next) { + // Finish the request, to let the appstore know we triggered the reboot + next(new HttpSuccess(202, {})); + + await safe(system.reboot()); +} + +async function isRebootRequired(req, res, next) { + const [error, rebootRequired] = await safe(system.isRebootRequired()); + if (error) return next(BoxError.toHttpError(error)); + + next(new HttpSuccess(200, { rebootRequired })); +} + +async function getDisks(req, res, next) { + const [getDisksError, disks] = await safe(system.getDisks()); + if (getDisksError) return next(BoxError.toHttpError(getDisksError)); + + let [getSwapsError, swaps] = await safe(system.getSwaps()); + if (getSwapsError) return next(BoxError.toHttpError(getSwapsError)); + + next(new HttpSuccess(200, { disks, swaps })); +} + +async function getDiskUsage(req, res, next) { + const [error, result] = await safe(system.getDiskUsage()); + if (error) return next(BoxError.toHttpError(error)); + + next(new HttpSuccess(200, { usage: result })); +} + +async function updateDiskUsage(req, res, next) { + const [error, taskId] = await safe(system.startUpdateDiskUsage()); + if (error) return next(BoxError.toHttpError(error)); + + next(new HttpSuccess(201, { taskId })); +} + +async function getMemory(req, res, next) { + const [error, result] = await safe(system.getMemory()); + if (error) return next(BoxError.toHttpError(error)); + + next(new HttpSuccess(200, result)); +} + +async function getLogs(req, res, next) { + assert.strictEqual(typeof req.params.unit, 'string'); + + const lines = 'lines' in req.query ? parseInt(req.query.lines, 10) : 10; // we ignore last-event-id + if (isNaN(lines)) return next(new HttpError(400, 'lines must be a number')); + + const options = { + lines: lines, + follow: false, + format: req.query.format || 'json' + }; + + const [error, logStream] = await safe(system.getLogs(req.params.unit, options)); + if (error) return next(BoxError.toHttpError(error)); + + res.writeHead(200, { + 'Content-Type': 'application/x-logs', + 'Content-Disposition': `attachment; filename="${req.params.unit}.log"`, + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no' // disable nginx buffering + }); + logStream.pipe(res); +} + +async function getLogStream(req, res, next) { + assert.strictEqual(typeof req.params.unit, 'string'); + + const lines = 'lines' in req.query ? parseInt(req.query.lines, 10) : 10; // we ignore last-event-id + if (isNaN(lines)) return next(new HttpError(400, 'lines must be a valid number')); + + function sse(id, data) { return 'id: ' + id + '\ndata: ' + data + '\n\n'; } + + if (req.headers.accept !== 'text/event-stream') return next(new HttpError(400, 'This API call requires EventStream')); + + const options = { + lines: lines, + follow: true, + format: req.query.format || 'json' + }; + + const [error, logStream] = await safe(system.getLogs(req.params.unit, options)); + if (error) return next(BoxError.toHttpError(error)); + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no', // disable nginx buffering + 'Access-Control-Allow-Origin': '*' + }); + res.write('retry: 3000\n'); + res.on('close', logStream.close); + logStream.on('data', function (data) { + const obj = JSON.parse(data); + res.write(sse(obj.realtimeTimestamp, JSON.stringify(obj))); // send timestamp as id + }); + logStream.on('end', res.end.bind(res)); + logStream.on('error', res.end.bind(res, null)); +} + +async function getSystemGraphs(req, res, next) { + if (!req.query.fromMinutes || !parseInt(req.query.fromMinutes)) return next(new HttpError(400, 'fromMinutes must be a number')); + + const fromMinutes = parseInt(req.query.fromMinutes); + const noNullPoints = !!req.query.noNullPoints; + const [error, result] = await safe(graphs.getSystem(fromMinutes, noNullPoints)); + if (error) return next(new HttpError(500, error)); + + next(new HttpSuccess(200, result)); +} + +async function getBlockDevices(req, res, next) { + const [error, devices] = await safe(system.getBlockDevices()); + if (error) return next(new HttpError(500, error)); + + next(new HttpSuccess(200, { devices })); +} diff --git a/src/routes/test/cloudron-test.js b/src/routes/test/cloudron-test.js index 613b9c256..b7dfea7e1 100644 --- a/src/routes/test/cloudron-test.js +++ b/src/routes/test/cloudron-test.js @@ -8,16 +8,11 @@ const constants = require('../../constants.js'), common = require('./common.js'), expect = require('expect.js'), - fs = require('fs'), - http = require('http'), - os = require('os'), - paths = require('../../paths.js'), - safe = require('safetydance'), superagent = require('superagent'), settings = require('../../settings.js'); describe('Cloudron API', function () { - const { setup, cleanup, serverUrl, owner, user, waitForTask } = common; + const { setup, cleanup, serverUrl, owner, user } = common; before(setup); after(cleanup); @@ -300,136 +295,6 @@ describe('Cloudron API', function () { }); }); - describe('logs', function () { - before(function () { - console.log(paths.BOX_LOG_FILE); - fs.writeFileSync(paths.BOX_LOG_FILE, '2022-11-06T15:06:20.009Z box:apphealthmonitor app health: 0 alive / 0 dead.\n', 'utf8'); - }); - - it('logStream - requires event-stream accept header', async function () { - const response = await superagent.get(`${serverUrl}/api/v1/cloudron/logstream/box`) - .query({ access_token: owner.token, fromLine: 0 }) - .ok(() => true); - - expect(response.statusCode).to.be(400); - }); - - it('logStream - stream logs', function (done) { - const options = { - host: 'localhost', - port: constants.PORT, - path: '/api/v1/cloudron/logstream/box?lines=10&access_token=' + owner.token, - headers: { 'Accept': 'text/event-stream', 'Connection': 'keep-alive' } - }; - - // superagent doesn't work. maybe https://github.com/visionmedia/superagent/issues/420 - const req = http.get(options, function (res) { - let data = ''; - res.on('data', function (d) { data += d.toString('utf8'); }); - setTimeout(function checkData() { - let dataMessageFound = false; - - expect(data.length).to.not.be(0); - data.split('\n').forEach(function (line) { - if (line.indexOf('id: ') === 0) { - expect(parseInt(line.substr('id: '.length), 10)).to.be.a('number'); - } else if (line.indexOf('data: ') === 0) { - const message = JSON.parse(line.slice('data: '.length)).message; - if (Array.isArray(message) || typeof message === 'string') dataMessageFound = true; - } - }); - - expect(dataMessageFound).to.be.ok(); - - res.destroy(); - req.destroy(); - done(); - }, 1000); - res.on('error', done); - }); - - req.on('error', done); - }); - }); - - describe('memory', function () { - it('cannot get without token', async function () { - const response = await superagent.get(`${serverUrl}/api/v1/cloudron/memory`) - .ok(() => true); - - expect(response.statusCode).to.equal(401); - }); - - it('succeeds (admin)', async function () { - const response = await superagent.get(`${serverUrl}/api/v1/cloudron/memory`) - .query({ access_token: owner.token }); - - expect(response.statusCode).to.equal(200); - expect(response.body.memory).to.eql(os.totalmem()); - expect(response.body.swap).to.be.a('number'); - }); - - it('fails (non-admin)', async function () { - const response = await superagent.get(`${serverUrl}/api/v1/cloudron/memory`) - .query({ access_token: user.token }) - .ok(() => true); - - expect(response.statusCode).to.equal(403); - }); - }); - - describe('disks', function () { - it('succeeds', async function () { - const response = await superagent.get(`${serverUrl}/api/v1/cloudron/disks`) - .query({ access_token: owner.token }); - - expect(response.statusCode).to.equal(200); - expect(response.body.disks).to.be.ok(); - expect(Object.keys(response.body.disks).some(fs => response.body.disks[fs].mountpoint === '/')).to.be(true); - }); - }); - - describe('disk usage', function () { - it('get succeeds with no cache', async function () { - safe.fs.unlinkSync(paths.DISK_USAGE_FILE); - - const response = await superagent.get(`${serverUrl}/api/v1/cloudron/disk_usage`) - .query({ access_token: owner.token }) - .send({}); - - expect(response.statusCode).to.equal(200); - expect(response.body).to.eql({ usage: null }); - }); - - it('update the cache', async function () { - const response = await superagent.post(`${serverUrl}/api/v1/cloudron/disk_usage`) - .query({ access_token: owner.token }); - - expect(response.statusCode).to.equal(201); - expect(response.body.taskId).to.be.ok(); - await waitForTask(response.body.taskId); - }); - - it('get succeeds with cache', async function () { - const response = await superagent.get(`${serverUrl}/api/v1/cloudron/disk_usage`) - .query({ access_token: owner.token }) - .send({}); - - expect(response.statusCode).to.equal(200); - expect(response.body.usage.ts).to.be.a('number'); - - const filesystems = Object.keys(response.body.usage.disks); - let dockerUsage = null; - for (const fs of filesystems) { - for (const content of response.body.usage.disks[fs].contents) { - if (content.id === 'docker') dockerUsage = content; - } - } - expect(dockerUsage).to.be.ok(); - expect(dockerUsage.usage).to.be.a('number'); - }); - }); - describe('languages', function () { it('succeeds', async function () { const response = await superagent.get(`${serverUrl}/api/v1/cloudron/languages`); @@ -439,103 +304,4 @@ describe('Cloudron API', function () { expect(response.body.languages.indexOf('en')).to.not.equal(-1); }); }); - - describe('blockdevices', function () { - it('succeeds', async function () { - const response = await superagent.get(`${serverUrl}/api/v1/cloudron/block_devices`) - .query({ access_token: owner.token }); - - expect(response.statusCode).to.equal(200); - expect(response.body.devices).to.be.ok(); - - expect(response.body.devices.some(d => d.mountpoint === '/')).to.be(true); - }); - }); - - describe('time_zone', function () { - it('succeeds', async function () { - const response = await superagent.get(`${serverUrl}/api/v1/cloudron/time_zone`) - .query({ access_token: owner.token }); - expect(response.statusCode).to.equal(200); - expect(response.body.timeZone).to.be('UTC'); - }); - - it('cannot set tz with missing timeZone', async function () { - const response = await superagent.post(`${serverUrl}/api/v1/cloudron/time_zone`) - .query({ access_token: owner.token }) - .send({ foo: 'bar' }) - .ok(() => true); - - expect(response.statusCode).to.equal(400); - }); - - it('cannot set language with invalid timeZone', async function () { - const response = await superagent.post(`${serverUrl}/api/v1/cloudron/time_zone`) - .query({ access_token: owner.token }) - .send({ timeZone: 'doesnotexist' }) - .ok(() => true); - - expect(response.statusCode).to.equal(400); - }); - - it('can set time zone', async function () { - const response = await superagent.post(`${serverUrl}/api/v1/cloudron/time_zone`) - .query({ access_token: owner.token }) - .send({ timeZone: 'Africa/Johannesburg' }); - - expect(response.statusCode).to.equal(200); - }); - - it('did set time zone', async function () { - const response = await superagent.get(`${serverUrl}/api/v1/cloudron/time_zone`) - .query({ access_token: owner.token }); - - expect(response.statusCode).to.equal(200); - expect(response.body.timeZone).to.equal('Africa/Johannesburg'); - }); - }); - - describe('language', function () { - it('can get default language', async function () { - const response = await superagent.get(`${serverUrl}/api/v1/cloudron/language`) - .query({ access_token: owner.token }); - - expect(response.statusCode).to.equal(200); - expect(response.body.language).to.equal('en'); - }); - - it('cannot set language with missing language', async function () { - const response = await superagent.post(`${serverUrl}/api/v1/cloudron/language`) - .query({ access_token: owner.token }) - .send({ foo: 'bar' }) - .ok(() => true); - - expect(response.statusCode).to.equal(400); - }); - - it('cannot set language with invalid language', async function () { - const response = await superagent.post(`${serverUrl}/api/v1/cloudron/language`) - .query({ access_token: owner.token }) - .send({ language: 'doesnotexist' }) - .ok(() => true); - - expect(response.statusCode).to.equal(400); - }); - - it('can set language', async function () { - const response = await superagent.post(`${serverUrl}/api/v1/cloudron/language`) - .query({ access_token: owner.token }) - .send({ language: 'de' }); - - expect(response.statusCode).to.equal(200); - }); - - it('did set language', async function () { - const response = await superagent.get(`${serverUrl}/api/v1/cloudron/language`) - .query({ access_token: owner.token }); - - expect(response.statusCode).to.equal(200); - expect(response.body.language).to.equal('de'); - }); - }); }); diff --git a/src/routes/test/system-test.js b/src/routes/test/system-test.js new file mode 100644 index 000000000..b2ffc92f5 --- /dev/null +++ b/src/routes/test/system-test.js @@ -0,0 +1,165 @@ +'use strict'; + +/* global it:false */ +/* global describe:false */ +/* global before:false */ +/* global after:false */ + +const constants = require('../../constants.js'), + common = require('./common.js'), + expect = require('expect.js'), + fs = require('fs'), + http = require('http'), + os = require('os'), + paths = require('../../paths.js'), + safe = require('safetydance'), + superagent = require('superagent'); + +describe('Cloudron API', function () { + const { setup, cleanup, serverUrl, owner, user, waitForTask } = common; + + before(setup); + after(cleanup); + + describe('logs', function () { + before(function () { + console.log(paths.BOX_LOG_FILE); + fs.writeFileSync(paths.BOX_LOG_FILE, '2022-11-06T15:06:20.009Z box:apphealthmonitor app health: 0 alive / 0 dead.\n', 'utf8'); + }); + + it('logStream - requires event-stream accept header', async function () { + const response = await superagent.get(`${serverUrl}/api/v1/system/logstream/box`) + .query({ access_token: owner.token, fromLine: 0 }) + .ok(() => true); + + expect(response.statusCode).to.be(400); + }); + + it('logStream - stream logs', function (done) { + const options = { + host: 'localhost', + port: constants.PORT, + path: '/api/v1/system/logstream/box?lines=10&access_token=' + owner.token, + headers: { 'Accept': 'text/event-stream', 'Connection': 'keep-alive' } + }; + + // superagent doesn't work. maybe https://github.com/visionmedia/superagent/issues/420 + const req = http.get(options, function (res) { + let data = ''; + res.on('data', function (d) { data += d.toString('utf8'); }); + setTimeout(function checkData() { + let dataMessageFound = false; + + expect(data.length).to.not.be(0); + data.split('\n').forEach(function (line) { + if (line.indexOf('id: ') === 0) { + expect(parseInt(line.substr('id: '.length), 10)).to.be.a('number'); + } else if (line.indexOf('data: ') === 0) { + const message = JSON.parse(line.slice('data: '.length)).message; + if (Array.isArray(message) || typeof message === 'string') dataMessageFound = true; + } + }); + + expect(dataMessageFound).to.be.ok(); + + res.destroy(); + req.destroy(); + done(); + }, 1000); + res.on('error', done); + }); + + req.on('error', done); + }); + }); + + describe('memory', function () { + it('cannot get without token', async function () { + const response = await superagent.get(`${serverUrl}/api/v1/system/memory`) + .ok(() => true); + + expect(response.statusCode).to.equal(401); + }); + + it('succeeds (admin)', async function () { + const response = await superagent.get(`${serverUrl}/api/v1/system/memory`) + .query({ access_token: owner.token }); + + expect(response.statusCode).to.equal(200); + expect(response.body.memory).to.eql(os.totalmem()); + expect(response.body.swap).to.be.a('number'); + }); + + it('fails (non-admin)', async function () { + const response = await superagent.get(`${serverUrl}/api/v1/system/memory`) + .query({ access_token: user.token }) + .ok(() => true); + + expect(response.statusCode).to.equal(403); + }); + }); + + describe('disks', function () { + it('succeeds', async function () { + const response = await superagent.get(`${serverUrl}/api/v1/system/disks`) + .query({ access_token: owner.token }); + + expect(response.statusCode).to.equal(200); + expect(response.body.disks).to.be.ok(); + expect(Object.keys(response.body.disks).some(fs => response.body.disks[fs].mountpoint === '/')).to.be(true); + }); + }); + + describe('disk usage', function () { + it('get succeeds with no cache', async function () { + safe.fs.unlinkSync(paths.DISK_USAGE_FILE); + + const response = await superagent.get(`${serverUrl}/api/v1/system/disk_usage`) + .query({ access_token: owner.token }) + .send({}); + + expect(response.statusCode).to.equal(200); + expect(response.body).to.eql({ usage: null }); + }); + + it('update the cache', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/system/disk_usage`) + .query({ access_token: owner.token }); + + expect(response.statusCode).to.equal(201); + expect(response.body.taskId).to.be.ok(); + await waitForTask(response.body.taskId); + }); + + it('get succeeds with cache', async function () { + const response = await superagent.get(`${serverUrl}/api/v1/system/disk_usage`) + .query({ access_token: owner.token }) + .send({}); + + expect(response.statusCode).to.equal(200); + expect(response.body.usage.ts).to.be.a('number'); + + const filesystems = Object.keys(response.body.usage.disks); + let dockerUsage = null; + for (const fs of filesystems) { + for (const content of response.body.usage.disks[fs].contents) { + if (content.id === 'docker') dockerUsage = content; + } + } + expect(dockerUsage).to.be.ok(); + expect(dockerUsage.usage).to.be.a('number'); + }); + }); + + describe('blockdevices', function () { + it('succeeds', async function () { + const response = await superagent.get(`${serverUrl}/api/v1/system/block_devices`) + .query({ access_token: owner.token }); + + expect(response.statusCode).to.equal(200); + expect(response.body.devices).to.be.ok(); + + expect(response.body.devices.some(d => d.mountpoint === '/')).to.be(true); + }); + }); +}); diff --git a/src/server.js b/src/server.js index 278cc4837..58383acf0 100644 --- a/src/server.js +++ b/src/server.js @@ -116,16 +116,17 @@ async function initializeExpressSync() { router.post('/api/v1/cloudron/prepare_dashboard_domain', json, token, authorizeAdmin, routes.cloudron.prepareDashboardDomain); router.post('/api/v1/cloudron/set_dashboard_domain', json, token, authorizeAdmin, routes.cloudron.updateDashboardDomain); - router.get ('/api/v1/cloudron/reboot', token, authorizeAdmin, routes.cloudron.isRebootRequired); - router.post('/api/v1/cloudron/reboot', json, token, authorizeAdmin, routes.cloudron.reboot); - router.get ('/api/v1/cloudron/graphs', token, authorizeAdmin, routes.cloudron.getSystemGraphs); - router.get ('/api/v1/cloudron/disks', token, authorizeAdmin, routes.cloudron.getDisks); - router.get ('/api/v1/cloudron/disk_usage', token, authorizeAdmin, routes.cloudron.getDiskUsage); - router.post('/api/v1/cloudron/disk_usage', token, authorizeAdmin, routes.cloudron.updateDiskUsage); - router.get ('/api/v1/cloudron/block_devices', token, authorizeAdmin, routes.cloudron.getBlockDevices); - router.get ('/api/v1/cloudron/memory', token, authorizeAdmin, routes.cloudron.getMemory); - router.get ('/api/v1/cloudron/logs/:unit', token, authorizeAdmin, routes.cloudron.getLogs); - router.get ('/api/v1/cloudron/logstream/:unit', token, authorizeAdmin, routes.cloudron.getLogStream); + // system (vm/server) + router.get ('/api/v1/system/reboot', token, authorizeAdmin, routes.system.isRebootRequired); + router.post('/api/v1/system/reboot', json, token, authorizeAdmin, routes.system.reboot); + router.get ('/api/v1/system/graphs', token, authorizeAdmin, routes.system.getSystemGraphs); + router.get ('/api/v1/system/disks', token, authorizeAdmin, routes.system.getDisks); + router.get ('/api/v1/system/disk_usage', token, authorizeAdmin, routes.system.getDiskUsage); + router.post('/api/v1/system/disk_usage', token, authorizeAdmin, routes.system.updateDiskUsage); + router.get ('/api/v1/system/block_devices', token, authorizeAdmin, routes.system.getBlockDevices); + router.get ('/api/v1/system/memory', token, authorizeAdmin, routes.system.getMemory); + router.get ('/api/v1/system/logs/:unit', token, authorizeAdmin, routes.system.getLogs); + router.get ('/api/v1/system/logstream/:unit', token, authorizeAdmin, routes.system.getLogStream); // eventlog router.get ('/api/v1/eventlog', token, authorizeAdmin, routes.eventlog.list); diff --git a/src/system.js b/src/system.js index cd8559dc3..db2585185 100644 --- a/src/system.js +++ b/src/system.js @@ -1,13 +1,19 @@ 'use strict'; exports = module.exports = { + reboot, + isRebootRequired, getDisks, getSwaps, checkDiskSpace, getMemory, getMemoryAllocation, getDiskUsage, - updateDiskUsage + updateDiskUsage, + startUpdateDiskUsage, + getLogs, + getBlockDevices, + runSystemChecks }; const apps = require('./apps.js'), @@ -17,16 +23,20 @@ const apps = require('./apps.js'), debug = require('debug')('box:disks'), df = require('./df.js'), docker = require('./docker.js'), + fs = require('fs'), + logs = require('./logs.js'), notifications = require('./notifications.js'), os = require('os'), path = require('path'), paths = require('./paths.js'), safe = require('safetydance'), shell = require('./shell.js'), + tasks = require('./tasks.js'), volumes = require('./volumes.js'); const DU_CMD = path.join(__dirname, 'scripts/du.sh'); const HDPARM_CMD = path.join(__dirname, 'scripts/hdparm.sh'); +const REBOOT_CMD = path.join(__dirname, 'scripts/reboot.sh'); async function du(file) { assert.strictEqual(typeof file, 'string'); @@ -249,3 +259,86 @@ async function updateDiskUsage(progressCallback) { return disks; } + +async function reboot() { + await notifications.clearAlert(notifications.ALERT_REBOOT, 'Reboot Required'); + + const [error] = await safe(shell.promises.sudo('reboot', [ REBOOT_CMD ], {})); + if (error) debug('reboot: could not reboot. %o', error); +} + +async function isRebootRequired() { + // https://serverfault.com/questions/92932/how-does-ubuntu-keep-track-of-the-system-restart-required-flag-in-motd + return fs.existsSync('/var/run/reboot-required'); +} + +async function startUpdateDiskUsage() { + const taskId = await tasks.add(tasks.TASK_UPDATE_DISK_USAGE, []); + tasks.startTask(taskId, {}); + return taskId; +} + +async function getLogs(unit, options) { + assert.strictEqual(typeof unit, 'string'); + assert(options && typeof options === 'object'); + + debug(`Getting logs for ${unit}`); + + let logFile = ''; + if (unit === 'box') logFile = path.join(paths.LOG_DIR, 'box.log'); // box.log is at the top + else throw new BoxError(BoxError.BAD_FIELD, `No such unit '${unit}'`); + + const cp = logs.tail([logFile], { lines: options.lines, follow: options.follow }); + + const logStream = new logs.LogStream({ format: options.format || 'json', source: unit }); + logStream.close = cp.kill.bind(cp, 'SIGKILL'); // hook for caller. closing stream kills the child process + + cp.stdout.pipe(logStream); + + return logStream; +} + +async function getBlockDevices() { + const info = safe.JSON.parse(safe.child_process.execSync('lsblk --paths --json --list --fs', { encoding: 'utf8' })); + if (!info) throw new BoxError(BoxError.INTERNAL_ERROR, safe.error.message); + + const devices = info.blockdevices.filter(d => d.fstype === 'ext4' || d.fstype === 'xfs'); + + debug(`getBlockDevices: Found ${devices.length} devices. ${devices.map(d => d.name).join(', ')}`); + + return devices.map(function (d) { + return { + path: d.name, + size: d.fsavail || 0, + type: d.fstype, + mountpoint: d.mountpoints ? d.mountpoints[0] : d.mountpoint // we only support one mountpoint here old lsblk only exposed one via .mountpoint + }; + }); +} + +async function checkRebootRequired() { + const rebootRequired = await isRebootRequired(); + if (rebootRequired) { + await notifications.alert(notifications.ALERT_REBOOT, 'Reboot Required', 'To finish ubuntu security updates, a reboot is necessary.', { persist: true }); + } else { + await notifications.clearAlert(notifications.ALERT_REBOOT, 'Reboot Required'); + } +} + +async function checkUbuntuVersion() { + const isXenial = fs.readFileSync('/etc/lsb-release', 'utf-8').includes('16.04'); + if (!isXenial) return; + + await notifications.alert(notifications.ALERT_UPDATE_UBUNTU, 'Ubuntu upgrade required', 'Ubuntu 16.04 has reached end of life and will not receive security and maintenance updates. Please follow https://docs.cloudron.io/guides/upgrade-ubuntu-18/ to upgrade to Ubuntu 18 at the earliest.', { persist: true }); +} + +async function runSystemChecks() { + debug('runSystemChecks: checking status'); + + const checks = [ + checkRebootRequired(), + checkUbuntuVersion() + ]; + + await Promise.allSettled(checks); +}