Files
cloudron-box/src/apps.js
2026-03-27 18:47:52 +01:00

3191 lines
133 KiB
JavaScript

import appTaskManager from './apptaskmanager.js';
import appstore from './appstore.js';
import archives from './archives.js';
import assert from 'node:assert';
import backups from './backups.js';
import backupSites from './backupsites.js';
import BoxError from './boxerror.js';
import constants from './constants.js';
import crypto from 'node:crypto';
import { CronTime } from 'cron';
import dashboard from './dashboard.js';
import database from './database.js';
import logger from './logger.js';
import dns from './dns.js';
import docker from './docker.js';
import domains from './domains.js';
import eventlog from './eventlog.js';
import fs from 'node:fs';
import fileUtils from './file-utils.js';
import Location from './location.js';
import locks from './locks.js';
import logs from './logs.js';
import mail from './mail.js';
import manifestFormat from '@cloudron/manifest-format';
import mysql from 'mysql2';
import notifications from './notifications.js';
import once from './once.js';
import path from 'node:path';
import paths from './paths.js';
import { PassThrough } from 'node:stream';
import reverseProxy from './reverseproxy.js';
import safe from 'safetydance';
import semver from 'semver';
import services from './services.js';
import shellModule from './shell.js';
import tasks from './tasks.js';
import { Transform as TransformStream } from 'node:stream';
import users from './users.js';
import util from 'node:util';
import volumes from './volumes.js';
import _ from './underscore.js';
const { log } = logger('apps');
const shell = shellModule('apps');
const PORT_TYPE_TCP = 'tcp';
const PORT_TYPE_UDP = 'udp';
const ISTATE_PENDING_INSTALL = 'pending_install';
const ISTATE_PENDING_CLONE = 'pending_clone';
const ISTATE_PENDING_CONFIGURE = 'pending_configure';
const ISTATE_PENDING_RECREATE_CONTAINER = 'pending_recreate_container';
const ISTATE_PENDING_LOCATION_CHANGE = 'pending_location_change';
const ISTATE_PENDING_SERVICES_CHANGE = 'pending_services_change';
const ISTATE_PENDING_DATA_DIR_MIGRATION = 'pending_data_dir_migration';
const ISTATE_PENDING_RESIZE = 'pending_resize';
const ISTATE_PENDING_DEBUG = 'pending_debug';
const ISTATE_PENDING_UNINSTALL = 'pending_uninstall';
const ISTATE_PENDING_RESTORE = 'pending_restore';
const ISTATE_PENDING_IMPORT = 'pending_import';
const ISTATE_PENDING_UPDATE = 'pending_update';
const ISTATE_PENDING_START = 'pending_start';
const ISTATE_PENDING_STOP = 'pending_stop';
const ISTATE_PENDING_RESTART = 'pending_restart';
const ISTATE_ERROR = 'error';
const ISTATE_INSTALLED = 'installed';
const RSTATE_RUNNING = 'running';
const RSTATE_STOPPED = 'stopped';
const ACCESS_LEVEL_ADMIN = 'admin';
const ACCESS_LEVEL_OPERATOR = 'operator';
const ACCESS_LEVEL_USER = 'user';
const ACCESS_LEVEL_NONE = '';
// NOTE: when adding fields here, update the clone and unarchive logic as well
const APPS_FIELDS_PREFIXED = [ 'apps.id', 'apps.appStoreId', 'apps.versionsUrl', 'apps.installationState', 'apps.errorJson', 'apps.runState',
'apps.health', 'apps.containerId', 'apps.manifestJson', 'apps.accessRestrictionJson', 'apps.memoryLimit', 'apps.cpuQuota',
'apps.label', 'apps.notes', 'apps.tagsJson', 'apps.taskId', 'apps.reverseProxyConfigJson', 'apps.servicesConfigJson', 'apps.operatorsJson',
'apps.sso', 'apps.devicesJson', 'apps.debugModeJson', 'apps.enableBackup', 'apps.proxyAuth', 'apps.containerIp', 'apps.crontab',
'apps.creationTime', 'apps.updateTime', 'apps.enableAutomaticUpdate', 'apps.upstreamUri', 'apps.checklistJson', 'apps.updateInfoJson',
'apps.enableMailbox', 'apps.mailboxDisplayName', 'apps.mailboxName', 'apps.mailboxDomain', 'apps.enableInbox', 'apps.inboxName', 'apps.inboxDomain',
'apps.enableTurn', 'apps.enableRedis', 'apps.storageVolumeId', 'apps.storageVolumePrefix', 'apps.ts', 'apps.healthTime', 'apps.buildConfigJson', '(apps.icon IS NOT NULL) AS hasIcon', '(apps.packageIcon IS NOT NULL) AS hasPackageIcon' ].join(',');
// const PORT_BINDINGS_FIELDS = [ 'hostPort', 'type', 'environmentVariable', 'appId', 'count' ].join(',');
const LOCATION_FIELDS = [ 'appId', 'subdomain', 'domain', 'type', 'certificateJson' ];
const CHECKVOLUME_CMD = path.join(import.meta.dirname, 'scripts/checkvolume.sh');
// ports is a map of envvar -> hostPort
function translateToPortBindings(ports, manifest) {
assert.strictEqual(typeof ports, 'object');
assert.strictEqual(typeof manifest, 'object');
const portBindings = {};
if (!ports) return portBindings;
const tcpPorts = manifest.tcpPorts || {};
for (const portName in ports) {
const portType = portName in tcpPorts ? PORT_TYPE_TCP : PORT_TYPE_UDP;
const portCount = portName in tcpPorts ? tcpPorts[portName].portCount : manifest.udpPorts[portName].portCount; // since count is optional, this can be undefined
portBindings[portName] = { hostPort: ports[portName], type: portType, count: portCount || 1 };
}
return portBindings;
}
// translates the REST API ports (envvar -> hostPort) to database portBindings (envvar -> { hostPort, count, type })
function validateSecondaryDomains(secondaryDomains, manifest) {
assert.strictEqual(typeof secondaryDomains, 'object');
assert.strictEqual(typeof manifest, 'object');
const httpPorts = manifest.httpPorts || {};
for (const envName in httpPorts) { // each httpPort is required
if (!(envName in secondaryDomains)) return new BoxError(BoxError.BAD_FIELD, `secondaryDomain ${envName} is required`);
}
for (const envName in secondaryDomains) {
if (!(envName in httpPorts)) return new BoxError(BoxError.BAD_FIELD, `secondaryDomain ${envName} is not listed in manifest`);
}
return null;
}
function translateSecondaryDomains(secondaryDomains) {
assert(secondaryDomains && typeof secondaryDomains === 'object');
const result = [];
for (const envName in secondaryDomains) {
result.push({ domain: secondaryDomains[envName].domain, subdomain: secondaryDomains[envName].subdomain, environmentVariable: envName });
}
return result;
}
function parseCrontab(crontab) {
assert(crontab === null || typeof crontab === 'string');
// https://www.man7.org/linux/man-pages/man5/crontab.5.html#EXTENSIONS
const KNOWN_EXTENSIONS = {
'@service' : '@service', // runs once
'@reboot' : '@service',
'@yearly' : '0 0 1 1 *',
'@annually' : '0 0 1 1 *',
'@monthly' : '0 0 1 * *',
'@weekly' : '0 0 * * 0',
'@daily' : '0 0 * * *',
'@hourly' : '0 * * * *',
};
const result = [];
if (!crontab) return result;
const lines = crontab.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line || line.startsWith('#')) continue;
if (line.startsWith('@')) {
const parts = /^(@\S+)\s+(.+)$/.exec(line);
if (!parts) throw new BoxError(BoxError.BAD_FIELD, `Invalid cron configuration at line ${i+1}`);
const [, extension, command] = parts;
if (!KNOWN_EXTENSIONS[extension]) throw new BoxError(BoxError.BAD_FIELD, `Unknown extension pattern at line ${i+1}`);
result.push({ schedule: KNOWN_EXTENSIONS[extension], command });
} else {
const parts = /^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.+)$/.exec(line);
if (!parts) throw new BoxError(BoxError.BAD_FIELD, `Invalid cron configuration at line ${i+1}`);
const schedule = parts.slice(1, 6).join(' ');
const command = parts[6];
try {
new CronTime('00 ' + schedule); // second is disallowed
} catch (error) {
throw new BoxError(BoxError.BAD_FIELD, `Invalid cron pattern at line ${i+1}: ${error.message}`);
}
if (command.length === 0) throw new BoxError(BoxError.BAD_FIELD, `Invalid cron pattern. Command must not be empty at line ${i+1}`); // not possible with the regexp we have
result.push({ schedule, command });
}
}
return result;
}
function getSchedulerConfig(app) {
assert.strictEqual(typeof app, 'object');
let schedulerConfig = app.manifest.addons?.scheduler || null;
const crontab = parseCrontab(app.crontab);
if (crontab.length === 0) return schedulerConfig;
schedulerConfig = schedulerConfig || {};
// put a '.' because it is not a valid name for schedule name in manifestformat
crontab.forEach((c, idx) => schedulerConfig[`crontab.${idx}`] = c);
return schedulerConfig;
}
function validateAccessRestriction(accessRestriction) {
assert.strictEqual(typeof accessRestriction, 'object');
if (accessRestriction === null) return null;
if (accessRestriction.users) {
if (!Array.isArray(accessRestriction.users)) return new BoxError(BoxError.BAD_FIELD, 'users array property required');
if (!accessRestriction.users.every(function (e) { return typeof e === 'string'; })) return new BoxError(BoxError.BAD_FIELD, 'All users have to be strings');
}
if (accessRestriction.groups) {
if (!Array.isArray(accessRestriction.groups)) return new BoxError(BoxError.BAD_FIELD, 'groups array property required');
if (!accessRestriction.groups.every(function (e) { return typeof e === 'string'; })) return new BoxError(BoxError.BAD_FIELD, 'All groups have to be strings');
}
// TODO: maybe validate if the users and groups actually exist
return null;
}
// also validates operators
function validateCpuQuota(cpuQuota) {
assert.strictEqual(typeof cpuQuota, 'number');
if (cpuQuota < 1 || cpuQuota > 100) return new BoxError(BoxError.BAD_FIELD, 'cpuQuota has to be between 1 and 100');
return null;
}
function validateDebugMode(debugMode) {
assert.strictEqual(typeof debugMode, 'object');
if (debugMode === null) return null;
if ('cmd' in debugMode && debugMode.cmd !== null && !Array.isArray(debugMode.cmd)) return new BoxError(BoxError.BAD_FIELD, 'debugMode.cmd must be an array or null' );
if ('readonlyRootfs' in debugMode && typeof debugMode.readonlyRootfs !== 'boolean') return new BoxError(BoxError.BAD_FIELD, 'debugMode.readonlyRootfs must be a boolean' );
return null;
}
function validateRobotsTxt(robotsTxt) {
if (robotsTxt === null) return null;
// this is the nginx limit on inline strings. if we really hit this, we have to generate a file
if (robotsTxt.length > 4096) return new BoxError(BoxError.BAD_FIELD, 'robotsTxt must be less than 4096');
// TODO: validate the robots file? we escape the string when templating the nginx config right now
return null;
}
function validateCsp(csp) {
if (csp === null) return null;
if (csp.length > 4096) return new BoxError(BoxError.BAD_FIELD, 'CSP must be less than 4096');
if (csp.includes('"')) return new BoxError(BoxError.BAD_FIELD, 'CSP cannot contains double quotes');
return null;
}
function validateUpstreamUri(upstreamUri) {
assert.strictEqual(typeof upstreamUri, 'string');
if (!upstreamUri) return new BoxError(BoxError.BAD_FIELD, 'upstreamUri cannot be empty');
const uri = safe(() => new URL(upstreamUri));
if (!uri) return new BoxError(BoxError.BAD_FIELD, `upstreamUri is invalid: ${safe.error.message}`);
if (uri.protocol !== 'http:' && uri.protocol !== 'https:') return new BoxError(BoxError.BAD_FIELD, 'upstreamUri has an unsupported scheme');
if (uri.search || uri.hash) return new BoxError(BoxError.BAD_FIELD, 'upstreamUri cannot have search or hash');
if (uri.pathname !== '/') return new BoxError(BoxError.BAD_FIELD, 'upstreamUri cannot have a path');
// we use the uri in a named location @wellknown-upstream. nginx does not support having paths in it
if (upstreamUri.endsWith('/')) return new BoxError(BoxError.BAD_FIELD, 'upstreamUri cannot have a path');
return null;
}
function validateLabel(label) {
if (label === null) return null;
if (label.length > 128) return new BoxError(BoxError.BAD_FIELD, 'label must be less than 128');
return null;
}
function validateTags(tags) {
if (tags.length > 64) return new BoxError(BoxError.BAD_FIELD, 'Can only set up to 64 tags');
if (tags.some(tag => tag.length == 0)) return new BoxError(BoxError.BAD_FIELD, 'tag cannot be empty');
if (tags.some(tag => tag.length > 128)) return new BoxError(BoxError.BAD_FIELD, 'tag must be less than 128');
return null;
}
function validateEnv(env) {
for (const key in env) {
if (key.length > 512) return new BoxError(BoxError.BAD_FIELD, 'Max env var key length is 512');
// http://pubs.opengroup.org/onlinepubs/000095399/basedefs/xbd_chap08.html
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) return new BoxError(BoxError.BAD_FIELD, `"${key}" is not a valid environment variable`);
}
return null;
}
function getDuplicateErrorDetails(errorMessage, locations, portBindings) {
assert.strictEqual(typeof errorMessage, 'string');
assert(Array.isArray(locations));
assert.strictEqual(typeof portBindings, 'object');
const match = errorMessage.match(/Duplicate entry '(.*)' for key '(.*)'/);
if (!match) {
log('Unexpected SQL error message.', errorMessage);
return new BoxError(BoxError.DATABASE_ERROR, errorMessage);
}
// check if a location conflicts
if (match[2] === 'locations.subdomain') {
for (let i = 0; i < locations.length; i++) {
const { subdomain, domain, type } = locations[i];
if (match[1] !== (subdomain ? `${subdomain}-${domain}` : domain)) continue;
return new BoxError(BoxError.ALREADY_EXISTS, `${type} location '${dns.fqdn(subdomain, domain)}' is in use`);
}
}
for (const portName in portBindings) {
if (portBindings[portName].hostPort === parseInt(match[1])) return new BoxError(BoxError.ALREADY_EXISTS, `Port ${match[1]} is in use`);
}
if (match[2] === 'apps_storageVolume') {
return new BoxError(BoxError.BAD_FIELD, `Storage directory ${match[1]} is in use`);
}
return new BoxError(BoxError.ALREADY_EXISTS, `${match[2]} '${match[1]}' is in use`);
}
function getMemoryLimit(app) {
assert.strictEqual(typeof app, 'object');
let memoryLimit = app.memoryLimit || app.manifest.memoryLimit || 0;
if (memoryLimit === -1) { // unrestricted
memoryLimit = 0;
} else if (memoryLimit === 0 || memoryLimit < constants.DEFAULT_MEMORY_LIMIT) { // ensure we never go below minimum (in case we change the default)
memoryLimit = constants.DEFAULT_MEMORY_LIMIT;
}
return memoryLimit;
}
function postProcess(result) {
assert.strictEqual(typeof result, 'object');
assert(result.manifestJson === null || typeof result.manifestJson === 'string');
result.manifest = safe.JSON.parse(result.manifestJson);
delete result.manifestJson;
assert(result.tagsJson === null || typeof result.tagsJson === 'string');
result.tags = safe.JSON.parse(result.tagsJson) || [];
delete result.tagsJson;
assert(result.checklistJson === null || typeof result.checklistJson === 'string');
result.checklist = safe.JSON.parse(result.checklistJson) || {};
delete result.checklistJson;
assert(result.updateInfoJson === null || typeof result.updateInfoJson === 'string');
result.updateInfo = safe.JSON.parse(result.updateInfoJson) || null;
delete result.updateInfoJson;
assert(result.reverseProxyConfigJson === null || typeof result.reverseProxyConfigJson === 'string');
result.reverseProxyConfig = safe.JSON.parse(result.reverseProxyConfigJson) || {};
delete result.reverseProxyConfigJson;
assert(result.hostPorts === null || typeof result.hostPorts === 'string');
assert(result.environmentVariables === null || typeof result.environmentVariables === 'string');
result.portBindings = {};
const hostPorts = result.hostPorts === null ? [ ] : result.hostPorts.split(',');
const environmentVariables = result.environmentVariables === null ? [ ] : result.environmentVariables.split(',');
const portTypes = result.portTypes === null ? [ ] : result.portTypes.split(',');
const portCounts = result.portCounts === null ? [ ] : result.portCounts.split(',');
delete result.hostPorts;
delete result.environmentVariables;
delete result.portTypes;
delete result.portCounts;
for (let i = 0; i < environmentVariables.length; i++) {
result.portBindings[environmentVariables[i]] = { hostPort: parseInt(hostPorts[i], 10), type: portTypes[i], count: parseInt(portCounts[i], 10) };
}
assert(result.accessRestrictionJson === null || typeof result.accessRestrictionJson === 'string');
result.accessRestriction = safe.JSON.parse(result.accessRestrictionJson);
if (result.accessRestriction && !result.accessRestriction.users) result.accessRestriction.users = [];
delete result.accessRestrictionJson;
result.operators = safe.JSON.parse(result.operatorsJson);
if (result.operators && !result.operators.users) result.operators.users = [];
delete result.operatorsJson;
result.sso = !!result.sso;
result.enableBackup = !!result.enableBackup;
result.enableAutomaticUpdate = !!result.enableAutomaticUpdate;
result.enableMailbox = !!result.enableMailbox;
result.enableInbox = !!result.enableInbox;
result.proxyAuth = !!result.proxyAuth;
result.hasIcon = !!result.hasIcon;
result.hasPackageIcon = !!result.hasPackageIcon;
assert(result.debugModeJson === null || typeof result.debugModeJson === 'string');
result.debugMode = safe.JSON.parse(result.debugModeJson);
delete result.debugModeJson;
assert(result.servicesConfigJson === null || typeof result.servicesConfigJson === 'string');
result.servicesConfig = safe.JSON.parse(result.servicesConfigJson) || {};
delete result.servicesConfigJson;
assert(result.buildConfigJson === null || typeof result.buildConfigJson === 'string');
result.buildConfig = safe.JSON.parse(result.buildConfigJson) || { buildArgs: [], dockerfileName: null };
delete result.buildConfigJson;
const subdomains = JSON.parse(result.subdomains),
parsedDomains = JSON.parse(result.domains),
subdomainTypes = JSON.parse(result.subdomainTypes),
subdomainEnvironmentVariables = JSON.parse(result.subdomainEnvironmentVariables),
subdomainCertificateJsons = JSON.parse(result.subdomainCertificateJsons);
delete result.subdomains;
delete result.domains;
delete result.subdomainTypes;
delete result.subdomainEnvironmentVariables;
delete result.subdomainCertificateJsons;
result.secondaryDomains = [];
result.redirectDomains = [];
result.aliasDomains = [];
for (let i = 0; i < subdomainTypes.length; i++) {
const subdomain = subdomains[i], domain = parsedDomains[i], certificate = safe.JSON.parse(subdomainCertificateJsons[i]);
if (subdomainTypes[i] === Location.TYPE_PRIMARY) {
result.subdomain = subdomain;
result.domain = domain;
result.certificate = certificate;
} else if (subdomainTypes[i] === Location.TYPE_SECONDARY) {
result.secondaryDomains.push({ domain, subdomain, certificate, environmentVariable: subdomainEnvironmentVariables[i] });
} else if (subdomainTypes[i] === Location.TYPE_REDIRECT) {
result.redirectDomains.push({ domain, subdomain, certificate });
} else if (subdomainTypes[i] === Location.TYPE_ALIAS) {
result.aliasDomains.push({ domain, subdomain, certificate });
}
}
const envNames = JSON.parse(result.envNames), envValues = JSON.parse(result.envValues);
delete result.envNames;
delete result.envValues;
result.env = {};
for (let i = 0; i < envNames.length; i++) { // NOTE: envNames is [ null ] when env of an app is empty
if (envNames[i]) result.env[envNames[i]] = envValues[i];
}
const volumeIds = JSON.parse(result.volumeIds);
delete result.volumeIds;
const volumeReadOnlys = JSON.parse(result.volumeReadOnlys);
delete result.volumeReadOnlys;
result.mounts = volumeIds[0] === null ? [] : volumeIds.map((v, idx) => { return { volumeId: v, readOnly: !!volumeReadOnlys[idx] }; }); // NOTE: volumeIds is [null] when volumes of an app is empty
result.error = safe.JSON.parse(result.errorJson);
delete result.errorJson;
result.taskId = result.taskId ? String(result.taskId) : null;
result.devices = result.devicesJson ? JSON.parse(result.devicesJson) : {};
delete result.devicesJson;
}
function isAdmin(user) {
assert.strictEqual(typeof user, 'object');
return users.compareRoles(user.role, users.ROLE_ADMIN) >= 0;
}
function isOperator(app, user) {
assert.strictEqual(typeof app, 'object'); // IMPORTANT: can also be applink
assert.strictEqual(typeof user, 'object');
if (!app.operators) return isAdmin(user);
if (app.operators.users.includes(user.id)) return true;
if (!app.operators.groups) return isAdmin(user);
if (app.operators.groups.some(function (gid) { return Array.isArray(user.groupIds) && user.groupIds.includes(gid); })) return true;
return isAdmin(user);
}
function canAccess(app, user) {
assert.strictEqual(typeof app, 'object'); // IMPORTANT: can also be applink
assert.strictEqual(typeof user, 'object');
if (app.accessRestriction === null) return true;
if (app.accessRestriction.users.includes(user.id)) return true;
if (!app.accessRestriction.groups) return isOperator(app, user);
if (app.accessRestriction.groups.some(function (gid) { return Array.isArray(user.groupIds) && user.groupIds.includes(gid); })) return true;
return isOperator(app, user);
}
function accessLevel(app, user) {
if (isAdmin(user)) return ACCESS_LEVEL_ADMIN;
if (isOperator(app, user)) return ACCESS_LEVEL_OPERATOR;
return canAccess(app, user) ? ACCESS_LEVEL_USER : ACCESS_LEVEL_NONE;
}
function pickFields(app, level) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof level, 'string');
if (level === ACCESS_LEVEL_NONE) return null; // cannot happen!
let result;
if (level === ACCESS_LEVEL_USER) {
result = _.pick(app, [
'id', 'appStoreId', 'versionsUrl', 'installationState', 'error', 'runState', 'health', 'taskId', 'accessRestriction',
'secondaryDomains', 'redirectDomains', 'aliasDomains', 'sso', 'subdomain', 'domain', 'fqdn', 'certificate',
'manifest', 'portBindings', 'iconUrl', 'creationTime', 'ts', 'tags', 'label', 'upstreamUri']);
} else { // admin or operator
result = _.pick(app, [
'id', 'appStoreId', 'versionsUrl', 'installationState', 'error', 'runState', 'health', 'taskId',
'subdomain', 'domain', 'fqdn', 'certificate', 'crontab', 'upstreamUri',
'accessRestriction', 'manifest', 'portBindings', 'iconUrl', 'memoryLimit', 'cpuQuota', 'operators',
'sso', 'debugMode', 'reverseProxyConfig', 'enableBackup', 'creationTime', 'updateTime', 'ts', 'tags',
'label', 'notes', 'secondaryDomains', 'redirectDomains', 'aliasDomains', 'devices', 'env', 'enableAutomaticUpdate',
'storageVolumeId', 'storageVolumePrefix', 'mounts', 'enableTurn', 'enableRedis', 'checklist',
'enableMailbox', 'mailboxDisplayName', 'mailboxName', 'mailboxDomain', 'enableInbox', 'inboxName', 'inboxDomain', 'updateInfo', 'buildConfig']);
}
// remove private certificate key
if (result.certificate) delete result.certificate.key;
result.secondaryDomains.forEach(sd => { if (sd.certificate) delete sd.certificate.key; });
result.aliasDomains.forEach(ad => { if (ad.certificate) delete ad.certificate.key; });
result.redirectDomains.forEach(rd => { if (rd.certificate) delete rd.certificate.key; });
return result;
}
async function checkForPortBindingConflict(portBindings, options) {
assert.strictEqual(typeof portBindings, 'object');
assert.strictEqual(typeof options, 'object');
const existingPortBindings = options.appId
? await database.query('SELECT * FROM appPortBindings WHERE appId != ?', [ options.appId ])
: await database.query('SELECT * FROM appPortBindings', []);
if (existingPortBindings.length === 0) return;
const tcpPortBindings = existingPortBindings.filter((p) => p.type === 'tcp');
const udpPortBindings = existingPortBindings.filter((p) => p.type === 'udp');
for (const portName in portBindings) {
const portBinding = portBindings[portName];
const existingPortBinding = portBinding.type === 'tcp' ? tcpPortBindings : udpPortBindings;
const found = existingPortBinding.find((epb) => {
// if one is true we dont have a conflict
// a1 <----> a2 b1 <-------> b2
// b1 <------> b2 a1 <-----> a2
const a2 = (epb.hostPort + epb.count - 1);
const b1 = portBinding.hostPort;
const b2 = (portBinding.hostPort + portBinding.count - 1);
const a1 = epb.hostPort;
return !((a2 < b1) || (b2 < a1));
});
if (found) throw new BoxError(BoxError.CONFLICT, `Conflicting ${portBinding.type} port ${portBinding.hostPort}`);
}
}
async function add(id, appStoreId, versionsUrl, manifest, subdomain, domain, portBindings, data) {
assert.strictEqual(typeof id, 'string');
assert.strictEqual(typeof appStoreId, 'string');
assert.strictEqual(typeof versionsUrl, 'string');
assert(manifest && typeof manifest === 'object');
assert.strictEqual(typeof manifest.version, 'string');
assert.strictEqual(typeof subdomain, 'string');
assert.strictEqual(typeof domain, 'string');
assert(portBindings && typeof portBindings === 'object');
assert(data && typeof data === 'object');
const manifestJson = JSON.stringify(manifest),
accessRestrictionJson = data.accessRestriction ? JSON.stringify(data.accessRestriction) : null,
operatorsJson = data.operators ? JSON.stringify(data.operators) : null,
memoryLimit = data.memoryLimit || 0,
cpuQuota = data.cpuQuota || 100,
installationState = data.installationState,
runState = data.runState,
sso = 'sso' in data ? data.sso : null,
debugModeJson = data.debugMode ? JSON.stringify(data.debugMode) : null,
devicesJson = data.devices ? JSON.stringify(data.devices) : null,
env = data.env || {},
label = data.label || null,
tagsJson = data.tags ? JSON.stringify(data.tags) : null,
checklistJson = data.checklist ? JSON.stringify(data.checklist) : null,
mailboxName = data.mailboxName || null,
mailboxDomain = data.mailboxDomain || null,
mailboxDisplayName = data.mailboxDisplayName || '',
reverseProxyConfigJson = data.reverseProxyConfig ? JSON.stringify(data.reverseProxyConfig) : null,
servicesConfigJson = data.servicesConfig ? JSON.stringify(data.servicesConfig) : null,
enableMailbox = data.enableMailbox || false,
upstreamUri = data.upstreamUri || '',
enableTurn = 'enableTurn' in data ? data.enableTurn : true,
icon = data.icon || null,
notes = data.notes || null,
crontab = data.crontab || null,
enableBackup = 'enableBackup' in data ? data.enableBackup : true,
enableAutomaticUpdate = 'enableAutomaticUpdate' in data ? data.enableAutomaticUpdate : true,
buildConfigJson = data.buildConfig ? JSON.stringify(data.buildConfig) : null;
// when redis is optional, do not enable it by default. it's mostly used for caching in those setups
const enableRedis = 'enableRedis' in data ? data.enableRedis : !manifest.addons?.redis?.optional;
await checkForPortBindingConflict(portBindings, { appId: null });
const queries = [];
queries.push({
query: 'INSERT INTO apps (id, appStoreId, versionsUrl, manifestJson, installationState, runState, accessRestrictionJson, operatorsJson, memoryLimit, cpuQuota, '
+ 'sso, debugModeJson, mailboxName, mailboxDomain, label, tagsJson, reverseProxyConfigJson, checklistJson, servicesConfigJson, icon, '
+ 'enableMailbox, mailboxDisplayName, upstreamUri, enableTurn, enableRedis, devicesJson, notes, crontab, enableBackup, enableAutomaticUpdate, buildConfigJson) '
+ ' VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
args: [ id, appStoreId, versionsUrl, manifestJson, installationState, runState, accessRestrictionJson, operatorsJson, memoryLimit, cpuQuota,
sso, debugModeJson, mailboxName, mailboxDomain, label, tagsJson, reverseProxyConfigJson, checklistJson, servicesConfigJson, icon,
enableMailbox, mailboxDisplayName, upstreamUri, enableTurn, enableRedis, devicesJson, notes, crontab,
enableBackup, enableAutomaticUpdate, buildConfigJson
]
});
queries.push({
query: 'INSERT INTO locations (appId, domain, subdomain, type) VALUES (?, ?, ?, ?)',
args: [ id, domain, subdomain, Location.TYPE_PRIMARY ]
});
Object.keys(portBindings).forEach(function (portEnv) {
queries.push({
query: 'INSERT INTO appPortBindings (environmentVariable, hostPort, type, appId, count) VALUES (?, ?, ?, ?, ?)',
args: [ portEnv, portBindings[portEnv].hostPort, portBindings[portEnv].type, id, portBindings[portEnv].count ]
});
});
Object.keys(env).forEach(function (name) {
queries.push({
query: 'INSERT INTO appEnvVars (appId, name, value) VALUES (?, ?, ?)',
args: [ id, name, env[name] ]
});
});
if (data.secondaryDomains) {
data.secondaryDomains.forEach(function (d) {
queries.push({
query: 'INSERT INTO locations (appId, domain, subdomain, type, environmentVariable) VALUES (?, ?, ?, ?, ?)',
args: [ id, d.domain, d.subdomain, Location.TYPE_SECONDARY, d.environmentVariable ]
});
});
}
if (data.redirectDomains) {
data.redirectDomains.forEach(function (d) {
queries.push({
query: 'INSERT INTO locations (appId, domain, subdomain, type) VALUES (?, ?, ?, ?)',
args: [ id, d.domain, d.subdomain, Location.TYPE_REDIRECT ]
});
});
}
if (data.aliasDomains) {
data.aliasDomains.forEach(function (d) {
queries.push({
query: 'INSERT INTO locations (appId, domain, subdomain, type) VALUES (?, ?, ?, ?)',
args: [ id, d.domain, d.subdomain, Location.TYPE_ALIAS ]
});
});
}
const [error] = await safe(database.transaction(queries));
if (error && error.sqlCode === 'ER_DUP_ENTRY') throw new BoxError(BoxError.ALREADY_EXISTS, error.message);
if (error && error.sqlCode === 'ER_NO_REFERENCED_ROW_2') throw new BoxError(BoxError.NOT_FOUND, 'no such domain');
if (error) throw new BoxError(BoxError.DATABASE_ERROR, error);
}
// note: this value cannot be cached as it depends on enableAutomaticUpdate and runState
async function getIcons(id) {
assert.strictEqual(typeof id, 'string');
const results = await database.query('SELECT icon, packageIcon FROM apps WHERE id = ?', [ id ]);
if (results.length === 0) return null;
return { icon: results[0].icon, packageIcon: results[0].packageIcon };
}
// attaches computed properties
async function getIcon(app, options) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof options, 'object');
const icons = await getIcons(app.id);
if (!icons) throw new BoxError(BoxError.NOT_FOUND, 'No such app');
if (!options.original && icons.icon) return icons.icon;
if (icons.packageIcon) return icons.packageIcon;
return null;
}
async function updateWithConstraints(id, app, constraints) {
assert.strictEqual(typeof id, 'string');
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof constraints, 'string');
assert(!('portBindings' in app) || typeof app.portBindings === 'object');
assert(!('accessRestriction' in app) || typeof app.accessRestriction === 'object' || app.accessRestriction === '');
assert(!('secondaryDomains' in app) || Array.isArray(app.secondaryDomains));
assert(!('redirectDomains' in app) || Array.isArray(app.redirectDomains));
assert(!('aliasDomains' in app) || Array.isArray(app.aliasDomains));
assert(!('tags' in app) || Array.isArray(app.tags));
assert(!('checklist' in app) || typeof app.checklist === 'object');
assert(!('env' in app) || typeof app.env === 'object');
const queries = [ ];
if ('portBindings' in app) {
const portBindings = app.portBindings;
await checkForPortBindingConflict(portBindings, { appId: id });
// replace entries by app id
queries.push({ query: 'DELETE FROM appPortBindings WHERE appId = ?', args: [ id ] });
Object.keys(portBindings).forEach(function (env) {
const values = [ portBindings[env].hostPort, portBindings[env].type, env, id, portBindings[env].count ];
queries.push({ query: 'INSERT INTO appPortBindings (hostPort, type, environmentVariable, appId, count) VALUES(?, ?, ?, ?, ?)', args: values });
});
}
if ('env' in app) {
queries.push({ query: 'DELETE FROM appEnvVars WHERE appId = ?', args: [ id ] });
Object.keys(app.env).forEach(function (name) {
queries.push({
query: 'INSERT INTO appEnvVars (appId, name, value) VALUES (?, ?, ?)',
args: [ id, name, app.env[name] ]
});
});
}
if ('subdomain' in app && 'domain' in app) { // must be updated together as they are unique together
queries.push({ query: 'DELETE FROM locations WHERE appId = ?', args: [ id ]}); // all locations of an app must be updated together
queries.push({ query: 'INSERT INTO locations (appId, domain, subdomain, type) VALUES (?, ?, ?, ?)', args: [ id, app.domain, app.subdomain, Location.TYPE_PRIMARY ]});
}
if ('secondaryDomains' in app) {
queries.push({ query: 'DELETE FROM locations WHERE appId = ? AND type = ?', args: [ id, Location.TYPE_SECONDARY ]});
app.secondaryDomains.forEach(function (d) {
queries.push({ query: 'INSERT INTO locations (appId, domain, subdomain, type, environmentVariable) VALUES (?, ?, ?, ?, ?)', args: [ id, d.domain, d.subdomain, Location.TYPE_SECONDARY, d.environmentVariable ]});
});
}
if ('redirectDomains' in app) {
queries.push({ query: 'DELETE FROM locations WHERE appId = ? AND type = ?', args: [ id, Location.TYPE_REDIRECT ]});
app.redirectDomains.forEach(function (d) {
queries.push({ query: 'INSERT INTO locations (appId, domain, subdomain, type) VALUES (?, ?, ?, ?)', args: [ id, d.domain, d.subdomain, Location.TYPE_REDIRECT ]});
});
}
if ('aliasDomains' in app) {
queries.push({ query: 'DELETE FROM locations WHERE appId = ? AND type = ?', args: [ id, Location.TYPE_ALIAS ]});
app.aliasDomains.forEach(function (d) {
queries.push({ query: 'INSERT INTO locations (appId, domain, subdomain, type) VALUES (?, ?, ?, ?)', args: [ id, d.domain, d.subdomain, Location.TYPE_ALIAS ]});
});
}
if ('mounts' in app) {
queries.push({ query: 'DELETE FROM appMounts WHERE appId = ?', args: [ id ]});
app.mounts.forEach(function (m) {
queries.push({ query: 'INSERT INTO appMounts (appId, volumeId, readOnly) VALUES (?, ?, ?)', args: [ id, m.volumeId, m.readOnly ]});
});
}
const fields = [ ], values = [ ];
for (const p in app) {
if (p === 'manifest' || p === 'tags' || p === 'checklist' || p === 'accessRestriction' || p === 'devices' || p === 'debugMode' || p === 'error'
|| p === 'reverseProxyConfig' || p === 'servicesConfig' || p === 'operators' || p === 'updateInfo' || p === 'buildConfig') {
fields.push(`${p}Json = ?`);
values.push(JSON.stringify(app[p]));
} else if (p !== 'portBindings' && p !== 'subdomain' && p !== 'domain' && p !== 'secondaryDomains' && p !== 'redirectDomains' && p !== 'aliasDomains' && p !== 'env' && p !== 'mounts') {
fields.push(p + ' = ?');
values.push(app[p]);
}
}
if (values.length !== 0) {
values.push(id);
queries.push({ query: 'UPDATE apps SET ' + fields.join(', ') + ' WHERE id = ? ' + constraints, args: values });
}
const [error, results] = await safe(database.transaction(queries));
if (error && error.sqlCode === 'ER_DUP_ENTRY') throw new BoxError(BoxError.ALREADY_EXISTS, error.message);
if (error) throw new BoxError(BoxError.DATABASE_ERROR, error);
if (results[results.length - 1].affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'App not found');
}
async function update(id, app) {
// ts is useful as a versioning mechanism (for example, icon changed). update the timestamp explicity in code instead of db.
// this way health and healthTime can be updated without changing ts
app.ts = new Date();
await updateWithConstraints(id, app, '');
}
function validateMemoryLimit(manifest, memoryLimit) {
assert.strictEqual(typeof manifest, 'object');
assert.strictEqual(typeof memoryLimit, 'number');
// max is not checked because docker allows any value for --memory
const min = manifest.memoryLimit || constants.DEFAULT_MEMORY_LIMIT;
// allow 0, which indicates that it is not set, the one from the manifest will be choosen but we don't commit any user value
// this is needed so an app update can change the value in the manifest, and if not set by the user, the new value should be used
if (memoryLimit === 0) return null;
// a special value that indicates unlimited memory
if (memoryLimit === -1) return null;
if (memoryLimit < min) return new BoxError(BoxError.BAD_FIELD, 'memoryLimit too small');
return null;
}
function canAutoupdateAppSync(app, updateInfo) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof updateInfo, 'object');
const manifest = updateInfo.manifest;
if (!app.enableAutomaticUpdate) return { code: false, reason: 'Automatic updates for the app is disabled' };
// for invalid subscriptions the appstore does not return a dockerImage
if (!manifest.dockerImage) return { code: false, reason: 'Invalid or Expired subscription' };
if (updateInfo.unstable) return { code: false, reason: 'Update is marked as unstable' }; // only manual update allowed for unstable updates
// for community apps, it's a warning sign when the repo changes (for example: versions domain gets hijacked)
if (docker.parseImageRef(manifest.dockerImage).fullRepositoryName !== docker.parseImageRef(app.manifest.dockerImage).fullRepositoryName) return { code: false, reason: 'Package docker image repository has changed' };
if ((semver.major(app.manifest.version) !== 0) && (semver.major(app.manifest.version) !== semver.major(manifest.version))) {
return { code: false, reason: 'Major package version requires review of breaking changes' }; // major changes are blocking
}
if (app.runState === RSTATE_STOPPED) return { code: false, reason: 'Stopped apps cannot run migration scripts' };
const newTcpPorts = manifest.tcpPorts || {};
const newUdpPorts = manifest.udpPorts || {};
const portBindings = app.portBindings; // this is never null
for (const portName in portBindings) {
if (!(portName in newTcpPorts) && !(portName in newUdpPorts)) return { code: false, reason: `${portName} port was in use but new update removes it` };
}
// it's fine if one or more (unused) port keys got removed
return { code: true, reason: '' };
}
function attachUpdateInfoProperties(app, updateInfo) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof updateInfo, 'object');
const { code, reason } = canAutoupdateAppSync(app, updateInfo);
updateInfo.isAutoUpdatable = code;
updateInfo.manualUpdateReason = reason;
}
function attachProperties(app, domainObjectMap) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof domainObjectMap, 'object');
app.iconUrl = app.hasIcon || app.hasPackageIcon ? `/api/v1/apps/${app.id}/icon` : null;
app.fqdn = dns.fqdn(app.subdomain, app.domain);
app.secondaryDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
app.redirectDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
app.aliasDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
if (app.updateInfo) attachUpdateInfoProperties(app, app.updateInfo);
}
async function setHealth(appId, health, healthTime) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof health, 'string');
assert(util.types.isDate(healthTime));
await updateWithConstraints(appId, { health, healthTime }, '');
}
async function setTask(appId, values, options) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof values, 'object');
assert.strictEqual(typeof options, 'object');
values.ts = new Date();
if (!options.requireNullTaskId) return await updateWithConstraints(appId, values, '');
if (options.requiredState === null) {
await updateWithConstraints(appId, values, 'AND taskId IS NULL');
} else {
await updateWithConstraints(appId, values, `AND taskId IS NULL AND installationState = "${options.requiredState}"`);
}
}
async function del(id) {
assert.strictEqual(typeof id, 'string');
const queries = [
{ query: 'DELETE FROM locations WHERE appId = ?', args: [ id ] },
{ query: 'DELETE FROM appPortBindings WHERE appId = ?', args: [ id ] },
{ query: 'DELETE FROM appEnvVars WHERE appId = ?', args: [ id ] },
{ query: 'DELETE FROM appPasswords WHERE identifier = ?', args: [ id ] },
{ query: 'DELETE FROM appMounts WHERE appId = ?', args: [ id ] },
{ query: `UPDATE backupSites SET contentsJson = JSON_REMOVE(contentsJson, JSON_UNQUOTE(JSON_SEARCH(contentsJson, 'one', ?, NULL, '$.*[*]'))) WHERE contentsJson LIKE ${mysql.escape('%"' + id + '"%')}`, args: [ id ] },
{ query: 'DELETE FROM apps WHERE id = ?', args: [ id ] }
];
const results = await database.transaction(queries);
if (results[6].affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'App not found');
}
async function clear() {
await database.query('DELETE FROM locations');
await database.query('DELETE FROM appPortBindings');
await database.query('DELETE FROM appAddonConfigs');
await database.query('DELETE FROM appEnvVars');
await database.query('DELETE FROM apps');
}
// each query simply join apps table with another table by id. we then join the full result together
const PB_QUERY = 'SELECT id, GROUP_CONCAT(CAST(appPortBindings.hostPort AS CHAR(6))) AS hostPorts, GROUP_CONCAT(appPortBindings.environmentVariable) AS environmentVariables, GROUP_CONCAT(appPortBindings.type) AS portTypes, GROUP_CONCAT(CAST(appPortBindings.count AS CHAR(6))) AS portCounts FROM apps LEFT JOIN appPortBindings ON apps.id = appPortBindings.appId GROUP BY apps.id';
const ENV_QUERY = 'SELECT id, JSON_ARRAYAGG(appEnvVars.name) AS envNames, JSON_ARRAYAGG(appEnvVars.value) AS envValues FROM apps LEFT JOIN appEnvVars ON apps.id = appEnvVars.appId GROUP BY apps.id';
const SUBDOMAIN_QUERY = 'SELECT id, JSON_ARRAYAGG(locations.subdomain) AS subdomains, JSON_ARRAYAGG(locations.domain) AS domains, JSON_ARRAYAGG(locations.type) AS subdomainTypes, JSON_ARRAYAGG(locations.environmentVariable) AS subdomainEnvironmentVariables, JSON_ARRAYAGG(locations.certificateJson) AS subdomainCertificateJsons FROM apps LEFT JOIN locations ON apps.id = locations.appId GROUP BY apps.id';
const MOUNTS_QUERY = 'SELECT id, JSON_ARRAYAGG(appMounts.volumeId) AS volumeIds, JSON_ARRAYAGG(appMounts.readOnly) AS volumeReadOnlys FROM apps LEFT JOIN appMounts ON apps.id = appMounts.appId GROUP BY apps.id';
const APPS_QUERY = `SELECT ${APPS_FIELDS_PREFIXED}, hostPorts, environmentVariables, portTypes, portCounts, envNames, envValues, subdomains, domains, subdomainTypes, subdomainEnvironmentVariables, subdomainCertificateJsons, volumeIds, volumeReadOnlys FROM apps`
+ ` LEFT JOIN (${PB_QUERY}) AS q1 on q1.id = apps.id`
+ ` LEFT JOIN (${ENV_QUERY}) AS q2 on q2.id = apps.id`
+ ` LEFT JOIN (${SUBDOMAIN_QUERY}) AS q3 on q3.id = apps.id`
+ ` LEFT JOIN (${MOUNTS_QUERY}) AS q4 on q4.id = apps.id`;
async function get(id) {
assert.strictEqual(typeof id, 'string');
const domainObjectMap = await domains.getDomainObjectMap();
const result = await database.query(`${APPS_QUERY} WHERE apps.id = ?`, [ id ]);
if (result.length === 0) return null;
postProcess(result[0]);
attachProperties(result[0], domainObjectMap);
return result[0];
}
async function getStorageDir(app) {
assert.strictEqual(typeof app, 'object');
if (!app.manifest.addons?.localstorage) return null;
if (!app.storageVolumeId) return path.join(paths.APPS_DATA_DIR, app.id, 'data');
const volume = await volumes.get(app.storageVolumeId);
if (!volume) throw new BoxError(BoxError.NOT_FOUND, 'Volume not found'); // not possible
return path.join(volume.hostPath, app.storageVolumePrefix);
}
async function checkStorage(app, volumeId, prefix) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof volumeId, 'string');
assert.strictEqual(typeof prefix, 'string');
const volume = await volumes.get(volumeId);
if (volume === null) throw new BoxError(BoxError.BAD_FIELD, 'Storage volume not found');
// lack of file perms makes these unsupported
if (volume.mountType === 'cifs' || volume.mountType === 'sshfs') throw new BoxError(BoxError.BAD_FIELD, `${volume.mountType} volumes cannot be used as data directory`);
const status = await volumes.getStatus(volume);
if (status.state !== 'active') throw new BoxError(BoxError.BAD_FIELD, 'Volume is not active');
if (path.isAbsolute(prefix)) throw new BoxError(BoxError.BAD_FIELD, `prefix "${prefix}" must be a relative path`);
if (prefix.endsWith('/')) throw new BoxError(BoxError.BAD_FIELD, `prefix "${prefix}" contains trailing slash`);
if (prefix !== '' && path.normalize(prefix) !== prefix) throw new BoxError(BoxError.BAD_FIELD, `prefix "${prefix}" is not a normalized path`);
const sourceDir = await getStorageDir(app);
if (sourceDir === null) throw new BoxError(BoxError.BAD_STATE, 'App does not use localstorage addon');
const targetDir = path.join(volume.hostPath, prefix);
const rel = path.relative(sourceDir, targetDir);
if (!rel.startsWith('../') && rel.split('/').length > 1) throw new BoxError(BoxError.BAD_FIELD, 'Only one level subdirectory moves are supported');
const [error] = await safe(shell.sudo([ CHECKVOLUME_CMD, targetDir, sourceDir ], {}));
if (error && error.code === 2) throw new BoxError(BoxError.BAD_FIELD, `Target directory ${targetDir} is not empty`);
if (error && error.code === 3) throw new BoxError(BoxError.BAD_FIELD, `Target directory ${targetDir} does not support chown`);
return null;
}
async function getByIpAddress(ip) {
assert.strictEqual(typeof ip, 'string');
const domainObjectMap = await domains.getDomainObjectMap();
const result = await database.query(`${APPS_QUERY} WHERE apps.containerIp = ?`, [ ip ]);
if (result.length === 0) return null;
postProcess(result[0]);
attachProperties(result[0], domainObjectMap);
return result[0];
}
async function list() {
const domainObjectMap = await domains.getDomainObjectMap();
const results = await database.query(`${APPS_QUERY} ORDER BY apps.id`, [ ]);
results.forEach(postProcess);
results.forEach((app) => attachProperties(app, domainObjectMap));
return results;
}
// returns the app associated with this IP (app or scheduler)
async function getByFqdn(fqdn) {
assert.strictEqual(typeof fqdn, 'string');
const result = await list();
const app = result.find(function (a) { return a.fqdn === fqdn; });
return app;
}
async function listByUser(user) {
assert.strictEqual(typeof user, 'object');
const result = await list();
return result.filter((app) => canAccess(app, user));
}
async function getTask(app) {
assert.strictEqual(typeof app, 'object');
if (!app.taskId) return null;
return await tasks.get(app.taskId);
}
function mailboxNameForSubdomain(subdomain, manifest) {
if (subdomain) return `${subdomain}.app`;
if (manifest.title) return manifest.title.toLowerCase().replace(/[^a-zA-Z0-9]/g, '') + '.app';
return 'noreply.app';
}
async function onTaskFinished(error, appId, installationState, taskId, auditSource) {
assert(!error || typeof error === 'object');
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof installationState, 'string');
assert.strictEqual(typeof taskId, 'string');
assert.strictEqual(typeof auditSource, 'object');
const success = !error;
const errorMessage = error?.message || null;
const app = await get(appId);
const task = await tasks.get(taskId);
switch (installationState) {
case ISTATE_PENDING_DATA_DIR_MIGRATION:
if (success) await safe(services.rebuildService('sftp', auditSource), { debug: log });
break;
case ISTATE_PENDING_UPDATE: {
const fromManifest = success ? task.args[1].updateConfig.manifest : app.manifest;
const toManifest = success ? app.manifest : task.args[1].updateConfig.manifest;
const backupError = error?.backupError || false;
await eventlog.add(eventlog.ACTION_APP_UPDATE_FINISH, auditSource, { app, toManifest, fromManifest, success, errorMessage, backupError });
await notifications.unpin(notifications.TYPE_MANUAL_APP_UPDATE_NEEDED, { context: app.id });
break;
}
}
// this can race with an new install task but very unlikely
log(`onTaskFinished: checking to stop unused services. hasPending: ${appTaskManager.hasPendingTasks()}`);
if (!appTaskManager.hasPendingTasks()) safe(services.stopUnusedServices(), { debug: log });
}
async function getCount() {
const result = await database.query('SELECT COUNT(*) AS total FROM apps');
return result[0].total;
}
async function setAccessRestriction(app, accessRestriction, auditSource) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof accessRestriction, 'object');
assert.strictEqual(typeof auditSource, 'object');
const appId = app.id;
const error = validateAccessRestriction(accessRestriction);
if (error) throw error;
await update(appId, { accessRestriction });
await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId, app, accessRestriction });
}
async function setOperators(app, operators, auditSource) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof operators, 'object');
assert.strictEqual(typeof auditSource, 'object');
const appId = app.id;
const error = validateAccessRestriction(operators); // not a typo. same structure for operators and accessRestriction
if (error) throw error;
await update(appId, { operators });
await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId, app, operators });
}
async function setCrontab(app, crontab, auditSource) {
assert.strictEqual(typeof app, 'object');
assert(crontab === null || typeof crontab === 'string');
assert.strictEqual(typeof auditSource, 'object');
const appId = app.id;
parseCrontab(crontab);
await update(appId, { crontab });
await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId, app, crontab });
}
async function setUpstreamUri(app, upstreamUri, auditSource) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof upstreamUri, 'string');
assert.strictEqual(typeof auditSource, 'object');
if (app.manifest.id !== constants.PROXY_APP_APPSTORE_ID) throw new BoxError(BoxError.BAD_FIELD, 'upstreamUri can only be set for proxy app');
const appId = app.id;
const error = validateUpstreamUri(upstreamUri);
if (error) throw error;
await reverseProxy.writeAppConfigs(Object.assign({}, app, { upstreamUri }));
await update(appId, { upstreamUri });
await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId, app, upstreamUri });
}
async function setLabel(app, label, auditSource) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof label, 'string');
assert.strictEqual(typeof auditSource, 'object');
const appId = app.id;
const error = validateLabel(label);
if (error) throw error;
await update(appId, { label });
await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId, app, label });
}
async function setTags(app, tags, auditSource) {
assert.strictEqual(typeof app, 'object');
assert(Array.isArray(tags));
assert.strictEqual(typeof auditSource, 'object');
const appId = app.id;
const error = validateTags(tags);
if (error) throw error;
await update(appId, { tags });
await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId, app, tags });
}
async function setNotes(app, notes, auditSource) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof notes, 'string');
assert.strictEqual(typeof auditSource, 'object');
await update(app.id, { notes });
await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId: app.id, app, notes });
}
async function setChecklistItem(app, checklistItemKey, acknowledged, auditSource) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof checklistItemKey, 'string');
assert.strictEqual(typeof acknowledged, 'boolean');
assert.strictEqual(typeof auditSource, 'object');
if (!app.checklist[checklistItemKey]) throw new BoxError(BoxError.NOT_FOUND, 'no such checklist item');
// nothing changed
if (app.checklist[checklistItemKey].acknowledged === acknowledged) return;
const checklist = app.checklist;
checklist[checklistItemKey].acknowledged = acknowledged;
checklist[checklistItemKey].changedAt = Date.now();
checklist[checklistItemKey].changedBy = auditSource.username;
await update(app.id, { checklist });
await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId: app.id, app, checklist });
}
async function setIcon(app, icon, auditSource) {
assert.strictEqual(typeof app, 'object');
assert(icon === null || typeof icon === 'string');
assert.strictEqual(typeof auditSource, 'object');
const appId = app.id;
if (icon) {
icon = Buffer.from(icon, 'base64');
if (icon.length === 0) throw new BoxError(BoxError.BAD_FIELD, 'icon is not base64');
}
await update(appId, { icon });
await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId, app, iconChanged: true });
}
async function setAutomaticBackup(app, enable, auditSource) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof enable, 'boolean');
assert.strictEqual(typeof auditSource, 'object');
const appId = app.id;
await update(appId, { enableBackup: enable });
await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId, app, enableBackup: enable });
}
async function setAutomaticUpdate(app, enable, auditSource) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof enable, 'boolean');
assert.strictEqual(typeof auditSource, 'object');
const appId = app.id;
await update(appId, { enableAutomaticUpdate: enable });
await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId, app, enableAutomaticUpdate: enable });
}
async function setReverseProxyConfig(app, reverseProxyConfig, auditSource) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof reverseProxyConfig, 'object');
assert.strictEqual(typeof auditSource, 'object');
reverseProxyConfig = Object.assign({ robotsTxt: null, csp: null, hstsPreload: false }, reverseProxyConfig);
const appId = app.id;
let error = validateCsp(reverseProxyConfig.csp);
if (error) throw error;
error = validateRobotsTxt(reverseProxyConfig.robotsTxt);
if (error) throw error;
await reverseProxy.writeAppConfigs(Object.assign({}, app, { reverseProxyConfig }));
await update(appId, { reverseProxyConfig });
await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId, app, reverseProxyConfig });
}
async function getLocation(subdomain, domain) {
assert.strictEqual(typeof subdomain, 'string');
assert.strictEqual(typeof domain, 'string');
const result = await database.query(`SELECT ${LOCATION_FIELDS} FROM locations WHERE subdomain=? AND domain=?`, [ subdomain, domain ]);
if (result.length === 0) return null;
return new Location(subdomain, domain, result[0].type, safe.JSON.parse(result[0].certificateJson));
}
async function validateLocations(locations) {
assert(Array.isArray(locations));
const domainObjectMap = await domains.getDomainObjectMap();
const RESERVED_SUBDOMAINS = [
constants.SMTP_SUBDOMAIN,
constants.IMAP_SUBDOMAIN
];
const dashboardLocation = await dashboard.getLocation();
for (const location of locations) {
if (!(location.domain in domainObjectMap)) return new BoxError(BoxError.BAD_FIELD, `No such domain in ${location.type} location`);
let subdomain = location.subdomain;
if (location.type === Location.TYPE_ALIAS && subdomain.startsWith('*')) {
if (subdomain === '*') continue;
subdomain = subdomain.replace(/^\*\./, ''); // remove *.
}
if (RESERVED_SUBDOMAINS.indexOf(subdomain) !== -1) return new BoxError(BoxError.BAD_FIELD, `subdomain '${subdomain}' is reserved`);
if (location.fqdn === dashboardLocation.fqdn) return new BoxError(BoxError.BAD_FIELD, `subdomain '${subdomain}' is reserved for dashboard`);
const error = dns.validateHostname(subdomain, location.domain);
if (error) return new BoxError(BoxError.BAD_FIELD, `Bad ${location.type} location: ${error.message}`);
}
return null;
}
async function setCertificate(app, data, auditSource) {
assert.strictEqual(typeof app, 'object');
assert(data && typeof data === 'object');
assert.strictEqual(typeof auditSource, 'object');
const { subdomain, domain, cert, key } = data;
const domainObject = await domains.get(domain);
if (domainObject === null) throw new BoxError(BoxError.NOT_FOUND, 'Domain not found');
if (cert && key) await reverseProxy.validateCertificate(subdomain, domain, { cert, key });
const certificate = cert && key ? { cert, key } : null;
const result = await database.query('UPDATE locations SET certificateJson=? WHERE location=? AND domain=?', [ certificate ? JSON.stringify(certificate) : null, subdomain, domain ]);
if (result.affectedRows === 0) throw new BoxError(BoxError.NOT_FOUND, 'Location not found');
const location = await getLocation(subdomain, domain); // fresh location object with type
await reverseProxy.setUserCertificate(app, location);
await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId: app.id, app, subdomain, domain, cert });
}
async function getLogPaths(app) {
assert.strictEqual(typeof app, 'object');
const appId = app.id;
const filePaths = [];
filePaths.push(path.join(paths.LOG_DIR, appId, 'apptask.log'));
filePaths.push(path.join(paths.LOG_DIR, appId, 'app.log'));
if (app.manifest.addons && app.manifest.addons.redis) filePaths.push(path.join(paths.LOG_DIR, `redis-${appId}/app.log`));
if (app.manifest.logPaths) {
const [error, result] = await safe(docker.inspect(app.containerId));
if (!error) {
const runVolume = result.Mounts.find(function (mount) { return mount.Destination === '/run'; });
const tmpVolume = result.Mounts.find(function (mount) { return mount.Destination === '/tmp'; });
const dataVolume = result.Mounts.find(function (mount) { return mount.Destination === '/app/data'; });
// note: wild cards are not supported yet in logPaths since that will require shell expansion
for (const logPath of app.manifest.logPaths) {
if (logPath.startsWith('/tmp/')) filePaths.push(`${tmpVolume.Source}/${logPath.slice('/tmp/'.length)}`);
else if (logPath.startsWith('/run/')) filePaths.push(`${runVolume.Source}/${logPath.slice('/run/'.length)}`);
else if (logPath.startsWith('/app/data/')) filePaths.push(`${dataVolume.Source}/${logPath.slice('/app/data/'.length)}`);
}
}
}
return filePaths;
}
async function getLogs(app, options) {
assert.strictEqual(typeof app, 'object');
assert(options && typeof options === 'object');
assert.strictEqual(typeof options.lines, 'number');
assert.strictEqual(typeof options.format, 'string');
assert.strictEqual(typeof options.follow, 'boolean');
const appId = app.id;
const logPaths = await getLogPaths(app);
const cp = logs.tail(logPaths, { lines: options.lines, follow: options.follow, sudo: true }); // need sudo access for paths inside app container (manifest.logPaths)
const logStream = new logs.LogStream({ format: options.format || 'json', source: appId });
logStream.on('close', () => cp.terminate()); // the caller has to call destroy() on logStream. destroy() of Transform emits 'close'
cp.stdout.pipe(logStream);
return logStream;
}
async function appendLogLine(app, line) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof line, 'string');
const logFilePath = path.join(paths.LOG_DIR, app.id, 'app.log');
const isoDate = new Date(new Date().toUTCString()).toISOString();
if (!safe.fs.appendFileSync(logFilePath, `${isoDate} ${line}\n`)) console.error(`Could not append log line for app ${app.id} at ${logFilePath}: ${safe.error.message}`);
}
async function checkManifest(manifest) {
assert(manifest && typeof manifest === 'object');
if (manifest.manifestVersion !== 2) return new BoxError(BoxError.BAD_FIELD, 'Manifest version must be 2');
if (!manifest.dockerImage) return new BoxError(BoxError.BAD_FIELD, 'Missing dockerImage'); // dockerImage is optional in manifest
if (semver.valid(manifest.maxBoxVersion) && semver.gt(constants.VERSION, manifest.maxBoxVersion)) {
return new BoxError(BoxError.BAD_FIELD, 'Box version exceeds Apps maxBoxVersion');
}
if (semver.valid(manifest.minBoxVersion) && semver.gt(manifest.minBoxVersion, constants.VERSION)) {
return new BoxError(BoxError.BAD_FIELD, 'App version requires a new platform version');
}
const error = await services.checkAddonsSupport(manifest.addons || {});
return error;
}
async function createExec(app, options) {
assert.strictEqual(typeof app, 'object');
assert(options && typeof options === 'object');
if (app.manifest.id === constants.PROXY_APP_APPSTORE_ID) throw new BoxError(BoxError.BAD_FIELD, 'cannot exec on proxy app');
const cmd = options.cmd || [ '/bin/bash' ];
assert(Array.isArray(cmd) && cmd.length > 0);
if (app.installationState !== ISTATE_INSTALLED || app.runState !== RSTATE_RUNNING) {
throw new BoxError(BoxError.BAD_STATE, 'App not installed or running');
}
const createOptions = {
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
// A pseudo tty is a terminal which processes can detect (for example, disable colored output)
// Creating a pseudo terminal also assigns a terminal driver which detects control sequences
// When passing binary data, tty must be disabled. In addition, the stdout/stderr becomes a single
// unified stream because of the nature of a tty (see https://github.com/docker/docker/issues/19696)
Tty: options.tty,
Cmd: cmd
};
// currently the webterminal and cli sets C.UTF-8
if (options.lang) createOptions.Env = [ 'LANG=' + options.lang ];
if (options.cwd) createOptions.WorkingDir = options.cwd;
return await docker.createExec(app.containerId, createOptions);
}
async function startExec(app, execId, options) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof execId, 'string');
assert(options && typeof options === 'object');
if (app.installationState !== ISTATE_INSTALLED || app.runState !== RSTATE_RUNNING) {
throw new BoxError(BoxError.BAD_STATE, 'App not installed or running');
}
const startOptions = {
Detach: false,
Tty: options.tty,
// hijacking upgrades the docker connection from http to tcp. because of this upgrade,
// we can work with half-close connections (not defined in http). this way, the client
// can properly signal that stdin is EOF by closing it's side of the socket. In http,
// the whole connection will be dropped when stdin get EOF.
// https://github.com/apocas/dockerode/commit/b4ae8a03707fad5de893f302e4972c1e758592fe
hijack: true,
stream: true,
stdin: true,
stdout: true,
stderr: true
};
const stream = await docker.startExec(execId, startOptions);
if (options.rows && options.columns) {
// there is a race where resizing too early results in a 404 "no such exec"
// https://git.cloudron.io/cloudron/box/issues/549
setTimeout(async function () {
await safe(docker.resizeExec(execId, { h: options.rows, w: options.columns }, { debug: log }));
}, 2000);
}
return stream;
}
async function getExec(app, execId) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof execId, 'string');
return await docker.getExec(execId);
}
async function listBackups(app, page, perPage) {
assert.strictEqual(typeof app, 'object');
assert(typeof page === 'number' && page > 0);
assert(typeof perPage === 'number' && perPage > 0);
return await backups.listByIdentifierAndStatePaged(app.id, backups.BACKUP_STATE_NORMAL, page, perPage);
}
async function listEventlog(app, filter, page, perPage) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof filter, 'object');
assert.strictEqual(typeof page, 'number');
assert.strictEqual(typeof perPage, 'number');
const fullFilter = {
actions: [],
search: app.id,
from: filter.from,
to: filter.to
};
return await eventlog.listPaged(fullFilter, page, perPage);
}
async function drainStream(stream) {
return new Promise((resolve, reject) => {
let data = '';
stream.setEncoding('utf8');
stream.on('error', (error) => reject(new BoxError.FS_ERROR, error.message));
stream.on('data', function (d) { data += d; });
stream.on('end', function () {
resolve(data);
});
});
}
async function downloadFile(app, filePath) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof filePath, 'string');
const statExecId = await createExec(app, { cmd: [ 'stat', '--printf=%F-%s', filePath ], tty: true });
const statStream = await startExec(app, statExecId, { tty: true });
const data = await drainStream(statStream);
const parts = data.split('-');
if (parts.length !== 2) throw new BoxError(BoxError.NOT_FOUND, 'file does not exist');
const type = parts[0];
let filename, cmd, size;
if (type === 'regular file') {
cmd = [ 'cat', filePath ];
size = parseInt(parts[1], 10);
filename = path.basename(filePath);
if (isNaN(size)) throw new BoxError(BoxError.NOT_FOUND, 'file does not exist');
} else if (type === 'directory') {
cmd = ['tar', 'zcf', '-', '-C', filePath, '.'];
filename = path.basename(filePath) + '.tar.gz';
size = 0; // unknown
} else {
throw new BoxError(BoxError.NOT_FOUND, 'only files or dirs can be downloaded');
}
const execId = await createExec(app, { cmd, tty: false });
const inputStream = await startExec(app, execId, { tty: false });
// transforms the docker stream into a normal stream
const stdoutStream = new TransformStream({
transform: function (chunk, ignoredEncoding, callback) {
this._buffer = this._buffer ? Buffer.concat([this._buffer, chunk]) : chunk;
for (;;) {
if (this._buffer.length < 8) break; // header is 8 bytes
const streamType = this._buffer.readUInt8(0);
const len = this._buffer.readUInt32BE(4);
if (this._buffer.length < (8 + len)) break; // not enough
const payload = this._buffer.slice(8, 8 + len);
this._buffer = this._buffer.slice(8+len); // consumed
if (streamType === 1) this.push(payload);
}
callback();
}
});
inputStream.pipe(stdoutStream);
return { stream: stdoutStream, filename, size };
}
async function uploadFile(app, sourceFilePath, destFilePath) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof sourceFilePath, 'string');
assert.strictEqual(typeof destFilePath, 'string');
// the built-in bash printf understands "%q" but not /usr/bin/printf.
// ' gets replaced with '\'' . the first closes the quote and last one starts a new one
const escapedDestFilePath = await shell.bash(`printf %q '${destFilePath.replace(/'/g, '\'\\\'\'')}'`, { encoding: 'utf8' });
log(`uploadFile: ${sourceFilePath} -> ${escapedDestFilePath}`);
const execId = await createExec(app, { cmd: [ 'bash', '-c', `cat - > ${escapedDestFilePath}` ], tty: false });
const destStream = await startExec(app, execId, { tty: false });
return new Promise((resolve, reject) => {
const done = once(error => reject(new BoxError(BoxError.FS_ERROR, error.message)));
const sourceStream = fs.createReadStream(sourceFilePath);
sourceStream.on('error', done);
destStream.on('error', done);
destStream.on('finish', resolve);
sourceStream.pipe(destStream);
});
}
async function writeConfig(app) {
assert.strictEqual(typeof app, 'object');
if (!safe.fs.writeFileSync(path.join(paths.APPS_DATA_DIR, app.id + '/config.json'), JSON.stringify(app, null, 4))) {
throw new BoxError(BoxError.FS_ERROR, 'Error creating config.json: ' + safe.error.message);
}
const [error, icons] = await safe(getIcons(app.id));
if (!error && icons.icon) safe.fs.writeFileSync(path.join(paths.APPS_DATA_DIR, app.id + '/icon.png'), icons.icon);
}
async function loadConfig(app) {
assert.strictEqual(typeof app, 'object');
const appConfig = safe.JSON.parse(safe.fs.readFileSync(path.join(paths.APPS_DATA_DIR, app.id + '/config.json')));
let data = {};
if (appConfig) {
data = _.pick(appConfig, ['memoryLimit', 'cpuQuota', 'enableBackup', 'reverseProxyConfig', 'env', 'servicesConfig', 'label', 'tags', 'enableAutomaticUpdate', 'buildConfig']);
}
const icon = safe.fs.readFileSync(path.join(paths.APPS_DATA_DIR, app.id + '/icon.png'));
if (icon) data.icon = icon;
await update(app.id, data);
}
function validatePorts(ports, manifest) {
assert.strictEqual(typeof ports, 'object');
assert.strictEqual(typeof manifest, 'object');
// keep the public ports in sync with firewall rules in setup/start/cloudron-firewall.sh
// these ports are reserved even if we listen only on 127.0.0.1 because we setup HostIp to be 127.0.0.1
// for custom tcp ports
const RESERVED_PORTS = [
22, /* ssh */
25, /* smtp */
80, /* http */
143, /* imap */
202, /* alternate ssh */
222, /* proftd */
443, /* https */
465, /* smtps */
587, /* submission */
993, /* imaps */
995, /* pop3s */
2003, /* graphite (lo) */
constants.PORT, /* app server (lo) */
constants.AUTHWALL_PORT, /* protected sites */
constants.INTERNAL_SMTP_PORT, /* internal smtp port (lo) */
constants.LDAP_PORT,
3306, /* mysql (lo) */
3478, /* turn,stun */
4190, /* managesieve */
5349, /* turn,stun TLS */
8000, /* ESXi monitoring */
];
const RESERVED_PORT_RANGES = [
[constants.TURN_UDP_PORT_START, constants.TURN_UDP_PORT_END] /* turn udp ports */
];
const ALLOWED_PORTS = [
53, // dns 53 is special and adblocker apps can use them
853 // dns over tls
];
if (!ports) return null;
const tcpPorts = manifest.tcpPorts || {};
const udpPorts = manifest.udpPorts || {};
for (const portName in ports) {
if (!/^[A-Z0-9_]+$/.test(portName)) return new BoxError(BoxError.BAD_FIELD, `${portName} is not a valid environment variable in ports`);
const hostPort = ports[portName];
if (!Number.isInteger(hostPort)) return new BoxError(BoxError.BAD_FIELD, `${hostPort} is not an integer in ${portName} ports`);
if (RESERVED_PORTS.indexOf(hostPort) !== -1) return new BoxError(BoxError.BAD_FIELD, `Port ${hostPort} for ${portName} is reserved in ports`);
if (RESERVED_PORT_RANGES.find(range => (hostPort >= range[0] && hostPort <= range[1]))) return new BoxError(BoxError.BAD_FIELD, `Port ${hostPort} for ${portName} is reserved in ports`);
if (ALLOWED_PORTS.indexOf(hostPort) === -1 && (hostPort <= 1023 || hostPort > 65535)) return new BoxError(BoxError.BAD_FIELD, `${hostPort} for ${portName} is not in permitted range in ports`);
// it is OK if there is no 1-1 mapping between values in manifest.tcpPorts and ports. missing values implies the service is disabled
const portSpec = tcpPorts[portName] || udpPorts[portName];
if (!portSpec) return new BoxError(BoxError.BAD_FIELD, `Invalid portBinding ${portName}`);
if (portSpec.readOnly && portSpec.defaultValue !== hostPort) return new BoxError(BoxError.BAD_FIELD, `portBinding ${portName} is readOnly and cannot have a different value that the default`);
if ((hostPort + (portSpec.portCount || 1)) > 65535) return new BoxError(BoxError.BAD_FIELD, `${hostPort}+${portSpec.portCount} for ${portName} exceeds valid port range`);
}
return null;
}
function validateDevices(devices) {
for (const key in devices) {
if (key.indexOf('/dev/') !== 0) return new BoxError(BoxError.BAD_FIELD, `"${key}" must start with /dev/`);
}
return null;
}
async function scheduleTask(appId, installationState, taskId, auditSource) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof installationState, 'string');
assert.strictEqual(typeof taskId, 'string');
assert.strictEqual(typeof auditSource, 'object');
let memoryLimit = 400;
if (installationState === ISTATE_PENDING_CLONE || installationState === ISTATE_PENDING_RESTORE
|| installationState === ISTATE_PENDING_IMPORT || installationState === ISTATE_PENDING_UPDATE) {
const sites = await backupSites.listByContentForUpdates(appId);
memoryLimit = sites.reduce((acc, cur) => cur.limits?.memoryLimit ? Math.max(cur.limits.memoryLimit/1024/1024, acc) : acc, 400);
} else if (installationState === ISTATE_PENDING_DATA_DIR_MIGRATION) {
memoryLimit = 1024; // cp takes more memory than we think
}
const options = { timeout: 20 * 60 * 60 * 1000 /* 20 hours */, nice: 15, memoryLimit };
appTaskManager.scheduleTask(appId, taskId, options, async function (error) {
log(`scheduleTask: task ${taskId} of ${appId} completed. error: %o`, error);
if (error?.code === tasks.ECRASHED || error?.code === tasks.ESTOPPED) { // if task crashed, update the error
log(`Apptask crashed/stopped: ${error.message}`);
const appError = {
message: error.message,
reason: BoxError.TASK_ERROR,
crashed: error.code === tasks.ECRASHED,
stopped: error.code === tasks.ESTOPPED,
taskId,
installationState
};
await safe(update(appId, { installationState: ISTATE_ERROR, error: appError, taskId: null }), { debug: log });
} else if (!(installationState === ISTATE_PENDING_UNINSTALL && !error)) { // clear out taskId except for successful uninstall
await safe(update(appId, { taskId: null }), { debug: log });
}
await safe(onTaskFinished(error, appId, installationState, taskId, auditSource), { debug: log }); // ignore error
});
}
async function addTask(appId, installationState, task, auditSource) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof installationState, 'string');
assert.strictEqual(typeof task, 'object'); // { args, values }
assert.strictEqual(typeof auditSource, 'object');
const { args, values } = task;
// TODO: match the SQL logic to match checkAppState. this means checking the error.installationState and installationState. Unfortunately, former is JSON right now
const requiredState = null; // 'requiredState' in task ? task.requiredState : ISTATE_INSTALLED;
const scheduleNow = 'scheduleNow' in task ? task.scheduleNow : true;
const requireNullTaskId = 'requireNullTaskId' in task ? task.requireNullTaskId : true;
const taskId = await tasks.add(tasks.TASK_APP, [ appId, args ]);
const [updateError] = await safe(setTask(appId, Object.assign({ installationState, taskId, error: null }, values), { requiredState, requireNullTaskId }));
if (updateError && updateError.reason === BoxError.NOT_FOUND) throw new BoxError(BoxError.BAD_STATE, 'Another task is scheduled for this app'); // could be because app went away OR a taskId exists
if (updateError) throw updateError;
if (scheduleNow) await safe(scheduleTask(appId, installationState, taskId, auditSource), { debug: log }); // ignore error
return taskId;
}
function checkAppState(app, state) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof state, 'string');
if (app.taskId) return new BoxError(BoxError.BAD_STATE, `Locked by task ${app.taskId} : ${app.installationState} / ${app.runState}`);
if (app.installationState === ISTATE_ERROR) {
// allow task to be called again if that was the errored task
if (app.error.installationState === state) return null;
// allow uninstall, restore, import and recreate (for mount/device/env edits) from any error state
if (state !== ISTATE_PENDING_UNINSTALL && state !== ISTATE_PENDING_RESTORE && state !== ISTATE_PENDING_IMPORT && state !== ISTATE_PENDING_RECREATE_CONTAINER) return new BoxError(BoxError.BAD_STATE, 'Not allowed in error state');
}
if (app.runState === RSTATE_STOPPED) {
// can't backup or restore since app addons are down. can't update because migration scripts won't run
if (state === ISTATE_PENDING_UPDATE || state === ISTATE_PENDING_RESTORE || state === ISTATE_PENDING_IMPORT) return new BoxError(BoxError.BAD_STATE, 'Not allowed in stopped state');
}
return null;
}
async function install(data, auditSource) {
assert(data && typeof data === 'object');
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof data.manifest, 'object'); // manifest is already downloaded
const features = appstore.getFeatures();
const installedAppCount = await getCount();
if (features.appMaxCount <= installedAppCount) throw new BoxError(BoxError.PLAN_LIMIT, 'app limit reached');
const subdomain = data.subdomain.toLowerCase(),
domain = data.domain.toLowerCase(),
accessRestriction = data.accessRestriction || null,
operators = data.operators || null,
memoryLimit = data.memoryLimit || 0,
cpuQuota = data.cpuQuota || 100,
debugMode = data.debugMode || null,
enableBackup = 'enableBackup' in data ? data.enableBackup : true,
enableAutomaticUpdate = 'enableAutomaticUpdate' in data ? data.enableAutomaticUpdate : true,
redirectDomains = data.redirectDomains || [],
aliasDomains = data.aliasDomains || [],
devices = data.devices || {},
env = data.env || {},
label = data.label || null,
tags = data.tags || [],
overwriteDns = 'overwriteDns' in data ? data.overwriteDns : false,
skipDnsSetup = 'skipDnsSetup' in data ? data.skipDnsSetup : false,
enableTurn = 'enableTurn' in data ? data.enableTurn : true,
appStoreId = data.appStoreId || '',
versionsUrl = data.versionsUrl || '',
upstreamUri = data.upstreamUri || '',
manifest = data.manifest,
notes = data.notes || null,
crontab = data.crontab || null;
// when redis is optional, do not enable it by default. it's mostly used for caching in those setups
const enableRedis = 'enableRedis' in data ? data.enableRedis : !manifest.addons?.redis?.optional;
let error = manifestFormat.parse(manifest);
if (error) throw new BoxError(BoxError.BAD_FIELD, `Manifest error: ${error.message}`);
if (data.sourceArchiveFilePath) manifest.dockerImage = `local/${manifest.id}:${manifest.version}-${Date.now()}`;
error = await checkManifest(manifest);
if (error) throw error;
error = validatePorts(data.ports || null, manifest);
if (error) throw error;
const portBindings = translateToPortBindings(data.ports || null, manifest);
error = validateAccessRestriction(accessRestriction);
if (error) throw error;
error = validateAccessRestriction(operators); // not a typo. same structure for operators and accessRestriction
if (error) throw error;
error = validateMemoryLimit(manifest, memoryLimit);
if (error) throw error;
error = validateDebugMode(debugMode);
if (error) throw error;
error = validateLabel(label);
if (error) throw error;
error = validateCpuQuota(cpuQuota);
if (error) throw error;
parseCrontab(crontab);
if ('upstreamUri' in data) error = validateUpstreamUri(upstreamUri);
if (error) throw error;
error = validateTags(tags);
if (error) throw error;
error = validateSecondaryDomains(data.secondaryDomains || {}, manifest);
if (error) throw error;
const secondaryDomains = translateSecondaryDomains(data.secondaryDomains || {});
let sso = 'sso' in data ? data.sso : null;
if ('sso' in data && !('optionalSso' in manifest)) throw new BoxError(BoxError.BAD_FIELD, 'sso can only be specified for apps with optionalSso');
// if sso was unspecified, enable it by default if possible
if (sso === null) sso = !!manifest.addons?.ldap || !!manifest.addons?.proxyAuth || !!manifest.addons?.oidc;
error = validateDevices(devices);
if (error) throw error;
error = validateEnv(env);
if (error) throw error;
if (constants.DEMO && constants.DEMO_BLOCKED_APPS.includes(appStoreId)) throw new BoxError(BoxError.BAD_FIELD, 'This app is blocked in the demo');
// sendmail is enabled by default
const enableMailbox = 'enableMailbox' in data ? data.enableMailbox : true;
const mailboxName = manifest.addons?.sendmail ? mailboxNameForSubdomain(subdomain, manifest) : null;
const mailboxDomain = manifest.addons?.sendmail ? domain : null;
let icon = data.icon || null;
if (icon) {
icon = Buffer.from(icon, 'base64');
if (icon.length === 0) throw new BoxError(BoxError.BAD_FIELD, 'icon is not base64');
}
const locations = [new Location(subdomain, domain, Location.TYPE_PRIMARY)]
.concat(secondaryDomains.map(sd => new Location(sd.subdomain, sd.domain, Location.TYPE_SECONDARY)))
.concat(redirectDomains.map(rd => new Location(rd.subdomain, rd.domain, Location.TYPE_REDIRECT)))
.concat(aliasDomains.map(ad => new Location(ad.subdomain, ad.domain, Location.TYPE_ALIAS)));
error = await validateLocations(locations);
if (error) throw error;
if (constants.DEMO && (await getCount() >= constants.DEMO_APP_LIMIT)) throw new BoxError(BoxError.BAD_STATE, 'Too many installed apps, please uninstall a few and try again');
const appId = crypto.randomUUID();
log(`Installing app ${appId}`);
const app = {
accessRestriction,
operators,
memoryLimit,
cpuQuota,
sso,
debugMode,
mailboxName,
mailboxDomain,
enableBackup,
enableAutomaticUpdate,
secondaryDomains,
redirectDomains,
aliasDomains,
env,
devices,
label,
tags,
icon,
enableMailbox,
upstreamUri,
enableTurn,
enableRedis,
notes,
crontab,
runState: RSTATE_RUNNING,
installationState: ISTATE_PENDING_INSTALL
};
const [addError] = await safe(add(appId, appStoreId, versionsUrl, manifest, subdomain, domain, portBindings, app));
if (addError && addError.reason === BoxError.ALREADY_EXISTS) throw getDuplicateErrorDetails(addError.message, locations, portBindings);
if (addError) throw addError;
if (data.sourceArchiveFilePath) await fileUtils.renameFile(data.sourceArchiveFilePath, `${paths.SOURCE_ARCHIVES_DIR}/${appId}.tar.gz`);
const buildConfig = {
buildArgs: data.buildConfig?.buildArgs || [],
dockerfileName: data.buildConfig?.dockerfileName || null
};
const task = {
args: { restoreConfig: null, skipDnsSetup, overwriteDns },
values: { buildConfig },
requiredState: app.installationState
};
const taskId = await addTask(appId, app.installationState, task, auditSource);
const newApp = Object.assign({}, _.omit(app, ['icon']), { appStoreId, versionsUrl, manifest, subdomain, domain, portBindings });
newApp.fqdn = dns.fqdn(newApp.subdomain, newApp.domain);
newApp.secondaryDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
newApp.redirectDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
newApp.aliasDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
await eventlog.add(eventlog.ACTION_APP_INSTALL, auditSource, { appId, sourceBuild: !!data.sourceArchiveFilePath, app: newApp, taskId });
return { id : appId, taskId };
}
async function setMemoryLimit(app, memoryLimit, auditSource) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof memoryLimit, 'number');
assert.strictEqual(typeof auditSource, 'object');
const appId = app.id;
let error = checkAppState(app, ISTATE_PENDING_RESIZE);
if (error) throw error;
error = validateMemoryLimit(app.manifest, memoryLimit);
if (error) throw error;
const task = {
args: {},
values: { memoryLimit }
};
const taskId = await addTask(appId, ISTATE_PENDING_RESIZE, task, auditSource);
await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId, app, memoryLimit, taskId });
return { taskId };
}
// never fails just prints error
async function setCpuQuota(app, cpuQuota, auditSource) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof cpuQuota, 'number');
assert.strictEqual(typeof auditSource, 'object');
const appId = app.id;
let error = checkAppState(app, ISTATE_PENDING_RESIZE);
if (error) throw error;
error = validateCpuQuota(cpuQuota);
if (error) throw error;
const task = {
args: {},
values: { cpuQuota }
};
const taskId = await safe(addTask(appId, ISTATE_PENDING_RESIZE, task, auditSource));
await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId, app, cpuQuota, taskId });
return { taskId };
}
// does a re-configure when called from most states. for install/clone errors, it re-installs with an optional manifest
// re-configure can take a dockerImage but not a manifest because re-configure does not clean up addons
async function setMounts(app, mounts, auditSource) {
assert.strictEqual(typeof app, 'object');
assert(Array.isArray(mounts));
assert.strictEqual(typeof auditSource, 'object');
const appId = app.id;
const error = checkAppState(app, ISTATE_PENDING_RECREATE_CONTAINER);
if (error) throw error;
const task = {
args: {},
values: { mounts }
};
const [taskError, taskId] = await safe(addTask(appId, ISTATE_PENDING_RECREATE_CONTAINER, task, auditSource));
if (taskError && taskError.reason === BoxError.ALREADY_EXISTS) throw new BoxError(BoxError.CONFLICT, 'Duplicate mount points');
if (taskError) throw taskError;
await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId, app, mounts, taskId });
return { taskId };
}
async function setDevices(app, devices, auditSource) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof devices, 'object');
assert.strictEqual(typeof auditSource, 'object');
const appId = app.id;
let error = checkAppState(app, ISTATE_PENDING_RECREATE_CONTAINER);
if (error) throw error;
error = validateDevices(devices);
if (error) throw error;
const task = {
args: {},
values: { devices }
};
const [taskError, taskId] = await safe(addTask(appId, ISTATE_PENDING_RECREATE_CONTAINER, task, auditSource));
if (taskError) throw taskError;
await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId, app, devices, taskId });
return { taskId };
}
async function setEnvironment(app, env, auditSource) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof env, 'object');
assert.strictEqual(typeof auditSource, 'object');
const appId = app.id;
let error = checkAppState(app, ISTATE_PENDING_RECREATE_CONTAINER);
if (error) throw error;
error = validateEnv(env);
if (error) throw error;
const task = {
args: {},
values: { env }
};
const taskId = await addTask(appId, ISTATE_PENDING_RECREATE_CONTAINER, task, auditSource);
await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId, app, env, taskId });
return { taskId };
}
async function setDebugMode(app, debugMode, auditSource) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof debugMode, 'object');
assert.strictEqual(typeof auditSource, 'object');
const appId = app.id;
let error = checkAppState(app, ISTATE_PENDING_DEBUG);
if (error) throw error;
error = validateDebugMode(debugMode);
if (error) throw error;
const task = {
args: {},
values: { debugMode }
};
const taskId = await addTask(appId, ISTATE_PENDING_DEBUG, task, auditSource);
await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId, app, debugMode, taskId });
return { taskId };
}
async function setMailbox(app, data, auditSource) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof data, 'object');
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof data.enable, 'boolean');
const enableMailbox = data.enable;
const appId = app.id;
let error = checkAppState(app, ISTATE_PENDING_SERVICES_CHANGE);
if (error) throw error;
if (!app.manifest.addons?.sendmail) throw new BoxError(BoxError.BAD_FIELD, 'App does not use sendmail');
const optional = 'optional' in app.manifest.addons.sendmail ? app.manifest.addons.sendmail.optional : false;
if (!optional && !enableMailbox) throw new BoxError(BoxError.BAD_FIELD, 'App requires sendmail to be enabled');
const mailboxDisplayName = data.mailboxDisplayName || '';
let mailboxName = data.mailboxName || null;
const mailboxDomain = data.mailboxDomain || null;
if (enableMailbox) {
await mail.getDomain(mailboxDomain); // check if domain exists
if (mailboxName) {
error = mail.validateName(mailboxName);
if (error) throw new BoxError(BoxError.BAD_FIELD, error.message);
} else {
mailboxName = mailboxNameForSubdomain(app.subdomain, app.domain, app.manifest);
}
if (mailboxDisplayName) {
error = mail.validateDisplayName(mailboxDisplayName);
if (error) throw new BoxError(BoxError.BAD_FIELD, error.message);
}
}
const task = {
args: {},
values: { enableMailbox, mailboxName, mailboxDomain, mailboxDisplayName }
};
const taskId = await addTask(appId, ISTATE_PENDING_SERVICES_CHANGE, task, auditSource);
await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId, app, mailboxName, mailboxDomain, mailboxDisplayName, taskId });
return { taskId };
}
async function setInbox(app, data, auditSource) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof data, 'object');
assert.strictEqual(typeof auditSource, 'object');
assert.strictEqual(typeof data.enable, 'boolean');
const enableInbox = data.enable;
const appId = app.id;
let error = checkAppState(app, ISTATE_PENDING_SERVICES_CHANGE);
if (error) throw error;
if (!app.manifest.addons?.recvmail) throw new BoxError(BoxError.BAD_FIELD, 'App does not use recvmail addon');
const inboxName = data.inboxName || null;
const inboxDomain = data.inboxDomain || null;
if (enableInbox) {
const domain = await mail.getDomain(data.inboxDomain); // check if domain exists
if (!domain.enabled) throw new BoxError(BoxError.BAD_FIELD, 'Domain does not have incoming email enabled');
error = mail.validateName(data.inboxName);
if (error) throw new BoxError(BoxError.BAD_FIELD, error.message);
}
const task = {
args: {},
values: { enableInbox, inboxName, inboxDomain }
};
const taskId = await addTask(appId, ISTATE_PENDING_SERVICES_CHANGE, task, auditSource);
await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId, app, enableInbox, inboxName, inboxDomain, taskId });
return { taskId };
}
async function setTurn(app, enableTurn, auditSource) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof enableTurn, 'boolean');
assert.strictEqual(typeof auditSource, 'object');
const appId = app.id;
const error = checkAppState(app, ISTATE_PENDING_SERVICES_CHANGE);
if (error) throw error;
if (!app.manifest.addons?.turn) throw new BoxError(BoxError.BAD_FIELD, 'App does not use turn addon');
if (!app.manifest.addons.turn.optional) throw new BoxError(BoxError.BAD_FIELD, 'turn service is not optional');
const task = {
args: {},
values: { enableTurn }
};
const taskId = await addTask(appId, ISTATE_PENDING_SERVICES_CHANGE, task, auditSource);
await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId, app, enableTurn, taskId });
return { taskId };
}
async function setRedis(app, enableRedis, auditSource) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof enableRedis, 'boolean');
assert.strictEqual(typeof auditSource, 'object');
const appId = app.id;
const error = checkAppState(app, ISTATE_PENDING_SERVICES_CHANGE);
if (error) throw error;
if (!app.manifest.addons?.redis) throw new BoxError(BoxError.BAD_FIELD, 'App does not use redis addon');
if (!app.manifest.addons.redis.optional) throw new BoxError(BoxError.BAD_FIELD, 'redis service is not optional');
const task = {
args: {},
values: { enableRedis }
};
const taskId = await addTask(appId, ISTATE_PENDING_SERVICES_CHANGE, task, auditSource);
await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId, app, enableRedis, taskId });
return { taskId };
}
async function setLocation(app, data, auditSource) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof data, 'object');
assert.strictEqual(typeof auditSource, 'object');
const appId = app.id;
let error = checkAppState(app, ISTATE_PENDING_LOCATION_CHANGE);
if (error) throw error;
const values = {
subdomain: data.subdomain.toLowerCase(),
domain: data.domain.toLowerCase(),
// these are intentionally reset, if not set
portBindings: {},
secondaryDomains: [],
redirectDomains: [],
aliasDomains: []
};
if ('ports' in data) {
error = validatePorts(data.ports || null, app.manifest);
if (error) throw error;
values.portBindings = translateToPortBindings(data.ports || null, app.manifest);
}
// rename the auto-created mailbox to match the new location
if (app.manifest.addons?.sendmail && app.mailboxName?.endsWith('.app')) {
values.mailboxName = mailboxNameForSubdomain(values.subdomain, app.manifest);
values.mailboxDomain = values.domain;
}
error = validateSecondaryDomains(data.secondaryDomains || {}, app.manifest);
if (error) throw error;
values.secondaryDomains = translateSecondaryDomains(data.secondaryDomains || {});
if ('redirectDomains' in data) {
values.redirectDomains = data.redirectDomains;
}
if ('aliasDomains' in data) {
values.aliasDomains = data.aliasDomains;
}
const locations = [new Location(values.subdomain, values.domain, Location.TYPE_PRIMARY)]
.concat(values.secondaryDomains.map(sd => new Location(sd.subdomain, sd.domain, Location.TYPE_SECONDARY)))
.concat(values.redirectDomains.map(rd => new Location(rd.subdomain, rd.domain, Location.TYPE_REDIRECT)))
.concat(values.aliasDomains.map(ad => new Location(ad.subdomain, ad.domain, Location.TYPE_ALIAS)));
error = await validateLocations(locations);
if (error) throw error;
const task = {
args: {
oldConfig: _.pick(app, ['subdomain', 'domain', 'fqdn', 'secondaryDomains', 'redirectDomains', 'aliasDomains', 'portBindings']),
skipDnsSetup: !!data.skipDnsSetup,
overwriteDns: !!data.overwriteDns
},
values
};
const [taskError, taskId] = await safe(addTask(appId, ISTATE_PENDING_LOCATION_CHANGE, task, auditSource));
if (taskError && taskError.reason !== BoxError.ALREADY_EXISTS) throw taskError;
if (taskError && taskError.reason === BoxError.ALREADY_EXISTS) throw getDuplicateErrorDetails(taskError.message, locations, values.portBindings);
values.fqdn = dns.fqdn(values.subdomain, values.domain);
values.secondaryDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
values.redirectDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
values.aliasDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, Object.assign({ appId, app, taskId }, values));
return { taskId };
}
async function setStorage(app, volumeId, volumePrefix, auditSource) {
assert.strictEqual(typeof app, 'object');
assert(volumeId === null || typeof volumeId === 'string');
assert(volumePrefix === null || typeof volumePrefix === 'string');
assert.strictEqual(typeof auditSource, 'object');
const appId = app.id;
const error = checkAppState(app, ISTATE_PENDING_DATA_DIR_MIGRATION);
if (error) throw error;
if (volumeId) {
await checkStorage(app, volumeId, volumePrefix);
} else {
volumeId = volumePrefix = null;
}
const task = {
args: { newStorageVolumeId: volumeId, newStorageVolumePrefix: volumePrefix },
values: {}
};
const taskId = await addTask(appId, ISTATE_PENDING_DATA_DIR_MIGRATION, task, auditSource);
await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId, app, volumeId, volumePrefix, taskId });
return { taskId };
}
async function updateApp(app, data, auditSource) {
assert.strictEqual(typeof app, 'object');
assert(data && typeof data === 'object');
assert(data.manifest && typeof data.manifest === 'object');
assert.strictEqual(typeof auditSource, 'object');
const skipBackup = !!data.skipBackup, appId = app.id, manifest = data.manifest;
const values = { updateInfo: null }; // clear update indicator immediately
if (app.runState === RSTATE_STOPPED) throw new BoxError(BoxError.BAD_STATE, 'Stopped apps cannot be updated');
let error = checkAppState(app, ISTATE_PENDING_UPDATE);
if (error) throw error;
error = manifestFormat.parse(manifest);
if (error) throw new BoxError(BoxError.BAD_FIELD, 'Manifest error:' + error.message);
if (data.sourceArchiveFilePath) manifest.dockerImage = `local/${manifest.id}:${manifest.version}-${Date.now()}`;
error = await checkManifest(manifest);
if (error) throw error;
if (!skipBackup) {
const sites = await backupSites.listByContentForUpdates(app.id);
if (sites.length === 0) throw new BoxError(BoxError.BAD_STATE, 'App has no backup site for updates', { backupError: true });
}
const updateConfig = { skipBackup, manifest }; // this will clear appStoreId/versionsUrl when updating from a repo and set it if passed in for update route
if ('appStoreId' in data) updateConfig.appStoreId = data.appStoreId;
if ('versionsUrl' in data) updateConfig.versionsUrl = data.versionsUrl;
updateConfig.buildConfig = {
buildArgs: data.buildConfig?.buildArgs || [],
dockerfileName: data.buildConfig?.dockerfileName || null
};
// prevent user from installing a app with different manifest id over an existing app
// this allows cloudron install -f --app <appid> for an app installed from the appStore
if (app.manifest.id !== updateConfig.manifest.id) {
if (!data.force) throw new BoxError(BoxError.BAD_FIELD, 'manifest id does not match. force to override');
}
// suffix '0' if prerelease is missing for semver.lte to work as expected
const currentVersion = semver.prerelease(app.manifest.version) ? app.manifest.version : `${app.manifest.version}-0`;
const updateVersion = semver.prerelease(updateConfig.manifest.version) ? updateConfig.manifest.version : `${updateConfig.manifest.version}-0`;
if ((app.appStoreId !== '' || app.versionsUrl !== '') && semver.lte(updateVersion, currentVersion)) {
if (!data.force) throw new BoxError(BoxError.BAD_FIELD, 'Downgrades are not permitted for apps installed from AppStore or Community. force to override');
}
if ('icon' in data) {
if (data.icon) {
data.icon = Buffer.from(data.icon, 'base64');
if (data.icon.length === 0) throw new BoxError(BoxError.BAD_FIELD, 'icon is not base64');
}
values.icon = data.icon;
}
// do not update apps in debug mode
if (app.debugMode && !data.force) throw new BoxError(BoxError.BAD_STATE, 'debug mode enabled. force to override');
// Ensure we update the memory limit in case the new app requires more memory as a minimum
// 0 and -1 are special updateConfig for memory limit indicating unset and unlimited
if (app.memoryLimit > 0 && updateConfig.manifest.memoryLimit && app.memoryLimit < updateConfig.manifest.memoryLimit) {
updateConfig.memoryLimit = updateConfig.manifest.memoryLimit;
}
if (!manifest.addons?.sendmail) { // clear if the update removed addon
values.mailboxName = values.mailboxDomain = null;
} else if (!app.mailboxName || app.mailboxName.endsWith('.app')) { // allocate since update added the addon
values.mailboxName = mailboxNameForSubdomain(app.subdomain, manifest);
values.mailboxDomain = app.domain;
}
if (!manifest.addons?.recvmail) { // clear if the update removed addon. required for fk constraint
values.enableInbox = false;
values.inboxName = values.inboxDomain = null;
}
const hasSso = !!updateConfig.manifest.addons?.proxyAuth || !!updateConfig.manifest.addons?.ldap || !!manifest.addons?.oidc;
if (!hasSso && app.sso) values.sso = false; // turn off sso flag, if the update removes sso options
if (data.sourceArchiveFilePath) await fileUtils.renameFile(data.sourceArchiveFilePath, `${paths.SOURCE_ARCHIVES_DIR}/${appId}.tar.gz`);
values.buildConfig = updateConfig.buildConfig;
const task = {
args: { updateConfig },
values
};
const taskId = await addTask(appId, ISTATE_PENDING_UPDATE, task, auditSource);
await eventlog.add(eventlog.ACTION_APP_UPDATE, auditSource, { appId, app, sourceBuild: !!data.sourceArchiveFilePath, skipBackup, toManifest: manifest, fromManifest: app.manifest, force: data.force, taskId });
return { taskId };
}
async function repair(app, data, auditSource) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof data, 'object'); // { manifest }
assert.strictEqual(typeof auditSource, 'object');
const appId = app.id;
let errorState = (app.error && app.error.installationState) || ISTATE_PENDING_CONFIGURE;
const task = {
args: {},
values: {},
requiredState: null
};
// maybe split this into a separate route like reinstall?
if (errorState === ISTATE_PENDING_INSTALL || errorState === ISTATE_PENDING_CLONE) {
task.args = { skipDnsSetup: false, overwriteDns: true };
if (data.manifest) {
let error = manifestFormat.parse(data.manifest);
if (error) throw new BoxError(BoxError.BAD_FIELD, `manifest error: ${error.message}`);
error = await checkManifest(data.manifest);
if (error) throw error;
if (!data.manifest.addons?.sendmail) { // clear if repair removed addon
task.values.mailboxName = task.values.mailboxDomain = null;
} else if (!app.mailboxName || app.mailboxName.endsWith('.app')) { // allocate since repair added the addon
task.values.mailboxName = mailboxNameForSubdomain(app.subdomain, data.manifest);
task.values.mailboxDomain = app.domain;
}
task.values.manifest = data.manifest;
task.args.oldManifest = app.manifest;
}
} else {
errorState = ISTATE_PENDING_CONFIGURE;
if (data.dockerImage) {
const newManifest = Object.assign({}, app.manifest, { dockerImage: data.dockerImage });
task.values.manifest = newManifest;
}
}
const taskId = await addTask(appId, errorState, task, auditSource);
await eventlog.add(eventlog.ACTION_APP_REPAIR, auditSource, { app, taskId });
return { taskId };
}
async function restore(app, backupId, auditSource) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof backupId, 'string');
assert.strictEqual(typeof auditSource, 'object');
const appId = app.id;
let error = checkAppState(app, ISTATE_PENDING_RESTORE);
if (error) throw error;
// for empty or null backupId, use existing manifest to mimic a reinstall
const restoreBackup = backupId ? await backups.get(backupId) : { manifest: app.manifest };
if (!restoreBackup) throw new BoxError(BoxError.BAD_FIELD, 'No such backup');
const manifest = restoreBackup.manifest;
if (!manifest) throw new BoxError(BoxError.EXTERNAL_ERROR, 'Could not get restore manifest');
if (restoreBackup.encryptionVersion === 1) throw new BoxError(BoxError.BAD_FIELD, 'This encrypted backup was created with an older Cloudron version and has to be restored using the CLI tool');
// re-validate because this new box version may not accept old configs
error = await checkManifest(manifest);
if (error) throw error;
const values = { manifest };
if (!manifest.addons?.sendmail) { // clear if restore removed addon
values.mailboxName = values.mailboxDomain = null;
} else if (!app.mailboxName || app.mailboxName.endsWith('.app')) { // allocate since restore added the addon
values.mailboxName = mailboxNameForSubdomain(app.subdomain, manifest);
values.mailboxDomain = app.domain;
}
if (!manifest.addons?.recvmail) { // recvmail is always optional. clear if restore removed addon
values.enableInbox = false;
values.inboxName = values.inboxDomain = null;
}
// prune secondaryDomains whose httpPorts no longer exist in the restored manifest
const newHttpPorts = manifest.httpPorts || {};
values.secondaryDomains = app.secondaryDomains.filter(sd => sd.environmentVariable in newHttpPorts);
// prune portBindings whose tcpPorts/udpPorts no longer exist in the restored manifest
const newTcpPorts = manifest.tcpPorts || {};
const newUdpPorts = manifest.udpPorts || {};
const portBindings = {};
for (const portName in app.portBindings) {
if (portName in newTcpPorts || portName in newUdpPorts) portBindings[portName] = app.portBindings[portName];
}
values.portBindings = portBindings;
const restoreConfig = { backupId: restoreBackup.id };
const task = {
args: {
restoreConfig,
oldManifest: app.manifest,
skipDnsSetup: !!backupId, // if this is a restore, just skip dns setup. only re-installs should setup dns
overwriteDns: true
},
values
};
const taskId = await addTask(appId, ISTATE_PENDING_RESTORE, task, auditSource);
await eventlog.add(eventlog.ACTION_APP_RESTORE, auditSource, { app, backupId: restoreBackup.id, remotePath: restoreBackup.remotePath, fromManifest: app.manifest, toManifest: manifest, taskId });
return { taskId };
}
async function importApp(app, data, auditSource) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof data, 'object');
assert.strictEqual(typeof auditSource, 'object');
const appId = app.id;
const error = checkAppState(app, ISTATE_PENDING_IMPORT);
if (error) throw error;
let restoreConfig;
if (data.remotePath) { // if not provided, we import in-place
const backupSite = await backupSites.createPseudo({
id: `appimport-${app.id}`,
provider: data.provider,
config: data.config,
format: data.format,
encryptionPassword: data.encryptionPassword ?? null,
encryptedFilenames: data.encryptedFilenames ?? false
});
restoreConfig = { remotePath: data.remotePath, backupSite };
} else { // inPlace
restoreConfig = { inPlace: true };
}
const task = {
args: {
restoreConfig,
oldManifest: app.manifest,
skipDnsSetup: false,
overwriteDns: true
},
values: {}
};
const taskId = await addTask(appId, ISTATE_PENDING_IMPORT, task, auditSource);
await eventlog.add(eventlog.ACTION_APP_IMPORT, auditSource, { app, remotePath: data.remotePath, inPlace: data.inPlace, taskId });
return { taskId };
}
function canBackupApp(app) {
// only backup apps that are installed or specific pending states
// stopped apps cannot be backed up because addons might be down (redis)
if (app.runState === RSTATE_STOPPED) return false;
// we used to check the health here but that doesn't work for stopped apps. it's better to just fail
// and inform the user if the backup fails and the app addons have not been setup yet.
return app.installationState === ISTATE_INSTALLED ||
app.installationState === ISTATE_PENDING_CONFIGURE ||
app.installationState === ISTATE_PENDING_UPDATE; // called from apptask
}
async function exportApp(app, backupSiteId, auditSource) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof backupSiteId, 'string'); // FIXME: this is not used at all in snapshotOnly mode
assert.strictEqual(typeof auditSource, 'object');
const appId = app.id;
if (!canBackupApp(app)) throw new BoxError(BoxError.BAD_STATE, 'App cannot be backed up in this state');
const taskId = await tasks.add(`${tasks.TASK_APP_BACKUP_PREFIX}${app.id}`, [ appId, backupSiteId, { snapshotOnly: true } ]);
safe(tasks.startTask(taskId, {}), { debug: log }); // background
return { taskId };
}
async function clone(app, data, user, auditSource) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof data, 'object');
assert(user && typeof user === 'object');
assert.strictEqual(typeof auditSource, 'object');
const subdomain = data.subdomain.toLowerCase(),
domain = data.domain.toLowerCase(),
backupId = data.backupId,
overwriteDns = 'overwriteDns' in data ? data.overwriteDns : false,
skipDnsSetup = 'skipDnsSetup' in data ? data.skipDnsSetup : false;
assert.strictEqual(typeof backupId, 'string');
assert.strictEqual(typeof subdomain, 'string');
assert.strictEqual(typeof domain, 'string');
const cloneBackup = await backups.get(backupId);
if (!cloneBackup) throw new BoxError(BoxError.NOT_FOUND, 'Backup not found');
if (!cloneBackup.manifest) throw new BoxError(BoxError.EXTERNAL_ERROR, 'Could not detect restore manifest');
if (cloneBackup.encryptionVersion === 1) throw new BoxError(BoxError.BAD_FIELD, 'This encrypted backup was created with an older Cloudron version and cannot be cloned');
const manifest = cloneBackup.manifest, appStoreId = app.appStoreId, versionsUrl = app.versionsUrl;
let error = validateSecondaryDomains(data.secondaryDomains || {}, manifest);
if (error) throw error;
const secondaryDomains = translateSecondaryDomains(data.secondaryDomains || {});
const locations = [new Location(subdomain, domain, Location.TYPE_PRIMARY)]
.concat(secondaryDomains.map(sd => new Location(sd.subdomain, sd.domain, Location.TYPE_SECONDARY)));
error = await validateLocations(locations);
if (error) throw error;
// re-validate because this new box version may not accept old configs
error = await checkManifest(manifest);
if (error) throw error;
error = validatePorts(data.ports || null, manifest);
if (error) throw error;
const portBindings = translateToPortBindings(data.ports || null, manifest);
// should we copy the original app's mailbox settings instead?
const mailboxName = manifest.addons?.sendmail ? mailboxNameForSubdomain(subdomain, manifest) : null;
const mailboxDomain = manifest.addons?.sendmail ? domain : null;
const newAppId = crypto.randomUUID();
// label, checklist intentionally omitted . icon is loaded in apptask from the backup
const dolly = _.pick(cloneBackup.appConfig || app, ['memoryLimit', 'cpuQuota', 'crontab', 'reverseProxyConfig', 'env', 'servicesConfig', 'tags', 'devices',
'enableMailbox', 'mailboxDisplayName', 'mailboxName', 'mailboxDomain', 'enableInbox', 'inboxName', 'inboxDomain', 'debugMode',
'enableTurn', 'enableRedis', 'mounts', 'enableBackup', 'enableAutomaticUpdate', 'accessRestriction', 'operators', 'sso',
'notes', 'checklist', 'buildConfig']);
if (!manifest.addons?.recvmail) dolly.inboxDomain = null; // needed because we are cloning _current_ app settings with old manifest
const obj = Object.assign(dolly, {
installationState: ISTATE_PENDING_CLONE,
runState: RSTATE_RUNNING,
mailboxName,
mailboxDomain,
secondaryDomains,
redirectDomains: [],
aliasDomains: [],
label: dolly.label ? `${dolly.label}-clone` : '',
});
const [addError] = await safe(add(newAppId, appStoreId, versionsUrl, manifest, subdomain, domain, portBindings, obj));
if (addError && addError.reason === BoxError.ALREADY_EXISTS) throw getDuplicateErrorDetails(addError.message, locations, portBindings);
if (addError) throw addError;
const restoreConfig = { backupId: cloneBackup.id };
const task = {
args: { restoreConfig, overwriteDns, skipDnsSetup, oldManifest: null },
values: {},
requiredState: ISTATE_PENDING_CLONE
};
const taskId = await addTask(newAppId, ISTATE_PENDING_CLONE, task, auditSource);
const newApp = Object.assign({}, _.omit(obj, ['icon']), { appStoreId, versionsUrl, manifest, subdomain, domain, portBindings });
newApp.fqdn = dns.fqdn(newApp.subdomain, newApp.domain);
newApp.secondaryDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
newApp.redirectDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
newApp.aliasDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
await eventlog.add(eventlog.ACTION_APP_CLONE, auditSource, { appId: newAppId, oldAppId: app.id, backupId, remotePath: cloneBackup.remotePath, oldApp: app, newApp, taskId });
return { id: newAppId, taskId };
}
async function unarchive(archiveEntry, data, auditSource) {
assert.strictEqual(typeof archiveEntry, 'object');
assert.strictEqual(typeof data, 'object');
assert(auditSource && typeof auditSource === 'object');
const archiveBackup = await backups.get(archiveEntry.backupId);
const restoreConfig = { backupId: archiveBackup.id };
const subdomain = data.subdomain.toLowerCase(),
domain = data.domain.toLowerCase(),
overwriteDns = 'overwriteDns' in data ? data.overwriteDns : false;
const manifest = archiveBackup.manifest, appStoreId = archiveBackup.manifest.id, versionsUrl = archiveBackup.appConfig?.versionsUrl || '';
let error = validateSecondaryDomains(data.secondaryDomains || {}, manifest);
if (error) throw error;
const secondaryDomains = translateSecondaryDomains(data.secondaryDomains || {});
const locations = [new Location(subdomain, domain, Location.TYPE_PRIMARY)]
.concat(secondaryDomains.map(sd => new Location(sd.subdomain, sd.domain, Location.TYPE_SECONDARY)));
error = await validateLocations(locations);
if (error) throw error;
// re-validate because this new box version may not accept old configs
error = await checkManifest(manifest);
if (error) throw error;
error = validatePorts(data.ports || null, manifest);
if (error) throw error;
const portBindings = translateToPortBindings(data.ports || null, manifest);
const appId = crypto.randomUUID();
// appConfig is null for pre-8.2 backups
const dolly = _.pick(archiveBackup.appConfig || {}, ['memoryLimit', 'cpuQuota', 'crontab', 'reverseProxyConfig', 'env', 'servicesConfig',
'tags', 'label', 'enableMailbox', 'mailboxDisplayName', 'mailboxName', 'mailboxDomain', 'enableInbox', 'inboxName', 'inboxDomain', 'devices',
'enableTurn', 'enableRedis', 'mounts', 'enableBackup', 'enableAutomaticUpdate', 'accessRestriction', 'operators', 'sso',
'notes', 'checklist', 'buildConfig']);
// intentionally not filled up: redirectDomain, aliasDomains, mailboxDomain
const obj = Object.assign(dolly, {
secondaryDomains,
redirectDomains: [],
aliasDomains: [],
mailboxDomain: data.domain, // archive's mailboxDomain may not exist
runState: RSTATE_RUNNING,
installationState: ISTATE_PENDING_INSTALL,
sso: archiveBackup.appConfig ? archiveBackup.appConfig.sso : true // when no appConfig take a blind guess
});
obj.icon = (await archives.getIcons(archiveEntry.id))?.icon;
const [addError] = await safe(add(appId, appStoreId, versionsUrl, manifest, subdomain, domain, portBindings, obj));
if (addError && addError.reason === BoxError.ALREADY_EXISTS) throw getDuplicateErrorDetails(addError.message, locations, portBindings);
if (addError) throw addError;
const task = {
args: { restoreConfig, overwriteDns },
values: {},
requiredState: obj.installationState
};
const taskId = await addTask(appId, obj.installationState, task, auditSource);
const newApp = Object.assign({}, _.omit(obj, ['icon']), { appStoreId, versionsUrl, manifest, subdomain, domain, portBindings });
newApp.fqdn = dns.fqdn(newApp.subdomain, newApp.domain);
newApp.secondaryDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
newApp.redirectDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
newApp.aliasDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
await eventlog.add(eventlog.ACTION_APP_INSTALL, auditSource, { appId, app: newApp, taskId });
return { id : appId, taskId };
}
async function uninstall(app, auditSource) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof auditSource, 'object');
const appId = app.id;
const error = checkAppState(app, ISTATE_PENDING_UNINSTALL);
if (error) throw error;
const task = {
args: {},
values: {},
requiredState: null // can run in any state, as long as no task is active
};
const taskId = await addTask(appId, ISTATE_PENDING_UNINSTALL, task, auditSource);
await eventlog.add(eventlog.ACTION_APP_UNINSTALL, auditSource, { appId, app, taskId });
return { taskId };
}
async function archive(app, backupId, auditSource) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof backupId, 'string');
assert.strictEqual(typeof auditSource, 'object');
if (app.manifest.id === constants.PROXY_APP_APPSTORE_ID) throw new BoxError(BoxError.BAD_FIELD, 'cannot archive proxy app');
const result = await backups.listByIdentifierAndStatePaged(app.id, backups.BACKUP_STATE_NORMAL, 1, 1);
if (result.length === 0) throw new BoxError(BoxError.BAD_STATE, 'No recent backup to archive');
if (result[0].id !== backupId) throw new BoxError(BoxError.BAD_STATE, 'Latest backup id has changed');
const icons = await getIcons(app.id);
const archiveId = await archives.add(backupId, { icon: icons.icon, packageIcon: icons.packageIcon, appConfig: app }, auditSource);
const { taskId } = await uninstall(app, auditSource);
return { taskId, id: archiveId };
}
async function start(app, auditSource) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof auditSource, 'object');
const appId = app.id;
const error = checkAppState(app, ISTATE_PENDING_START);
if (error) throw error;
const task = {
args: {},
values: { runState: RSTATE_RUNNING }
};
const taskId = await addTask(appId, ISTATE_PENDING_START, task, auditSource);
await eventlog.add(eventlog.ACTION_APP_START, auditSource, { appId, app, taskId });
return { taskId };
}
async function stop(app, auditSource) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof auditSource, 'object');
const appId = app.id;
const error = checkAppState(app, ISTATE_PENDING_STOP);
if (error) throw error;
const task = {
args: {},
values: { runState: RSTATE_STOPPED }
};
const taskId = await addTask(appId, ISTATE_PENDING_STOP, task, auditSource);
await eventlog.add(eventlog.ACTION_APP_STOP, auditSource, { appId, app, taskId });
return { taskId };
}
async function restart(app, auditSource) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof auditSource, 'object');
const appId = app.id;
const error = checkAppState(app, ISTATE_PENDING_RESTART);
if (error) throw error;
const task = {
args: {},
values: { runState: RSTATE_RUNNING }
};
const taskId = await addTask(appId, ISTATE_PENDING_RESTART, task, auditSource);
await eventlog.add(eventlog.ACTION_APP_RESTART, auditSource, { appId, app, taskId });
return { taskId };
}
// auto-restart app tasks after a crash
async function backup(app, backupSiteId, auditSource) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof backupSiteId, 'string');
assert.strictEqual(typeof auditSource, 'object');
if (!canBackupApp(app)) throw new BoxError(BoxError.BAD_STATE, 'App cannot be backed up in this state');
const backupSite = await backupSites.get(backupSiteId);
if (!backupSite) throw new BoxError(BoxError.BAD_FIELD, 'No such backup site');
const taskId = await tasks.add(`${tasks.TASK_APP_BACKUP_PREFIX}${app.id}`, [ app.id, backupSite.id, { snapshotOnly: false } ]);
const memoryLimit = backupSite.limits?.memoryLimit ? Math.max(backupSite.limits.memoryLimit/1024/1024, 1024) : 1024;
// background
tasks.startTask(taskId, { timeout: 24 * 60 * 60 * 1000 /* 24 hours */, nice: 15, memoryLimit, oomScoreAdjust: -999 })
.then(async (backupId) => {
const completedBackup = await backups.get(backupId); // if task crashed, no result
await eventlog.add(eventlog.ACTION_APP_BACKUP_FINISH, auditSource, { app, success: !!completedBackup, errorMessage: '', remotePath: completedBackup?.remotePath, backupId: backupId });
})
.catch(async (error) => {
await eventlog.add(eventlog.ACTION_APP_BACKUP_FINISH, auditSource, { app, success: false, errorMessage: error.message });
})
.finally(async () => {
await locks.releaseByTaskId(taskId);
});
await eventlog.add(eventlog.ACTION_APP_BACKUP, auditSource, { app, appId: app.id, taskId });
return { taskId };
}
async function updateBackup(app, backupId, data) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof backupId, 'string');
assert.strictEqual(typeof data, 'object');
const appBackup = await backups.get(backupId);
if (!appBackup) throw new BoxError(BoxError.NOT_FOUND, 'Backup not found');
if (appBackup.identifier !== app.id) throw new BoxError(BoxError.NOT_FOUND, 'Backup not found'); // some other app's backup
await backups.update(appBackup, data);
}
async function getBackupDownloadStream(app, backupId) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof backupId, 'string');
const downloadBackup = await backups.get(backupId);
if (!downloadBackup) throw new BoxError(BoxError.NOT_FOUND, 'Backup not found');
if (downloadBackup.identifier !== app.id) throw new BoxError(BoxError.NOT_FOUND, 'Backup not found'); // some other app's backup
const backupSite = await backupSites.get(downloadBackup.siteId);
if (!backupSite) throw new BoxError(BoxError.NOT_FOUND, 'Backup site not found'); // not possible
if (backupSite.format !== 'tgz') throw new BoxError(BoxError.BAD_STATE, 'only tgz backups can be downloaded');
const ps = new PassThrough();
const stream = await backupSites.storageApi(backupSite).download(backupSite.config, downloadBackup.remotePath);
stream.on('error', function(error) {
log(`getBackupDownloadStream: read stream error: ${error.message}`);
ps.emit('error', new BoxError(BoxError.EXTERNAL_ERROR, error));
});
stream.pipe(ps);
const now = (new Date()).toISOString().replace(/:|T/g,'-').replace(/\..*/,'');
const encryptionSuffix = downloadBackup.encryptionVersion ? '.enc' : '';
const filename = `app-backup-${now} (${app.fqdn}).tar.gz${encryptionSuffix}`;
return { stream: ps, filename };
}
async function restoreApps(apps, options, auditSource) {
assert(Array.isArray(apps));
assert.strictEqual(typeof options, 'object');
assert.strictEqual(typeof auditSource, 'object');
apps = apps.filter(app => app.installationState !== ISTATE_ERROR); // remove errored apps. let them be 'repaired' by hand
apps = apps.filter(app => app.installationState !== ISTATE_PENDING_RESTORE); // safeguard against tasks being created non-stop if we crash on startup
for (const app of apps) {
const [error, results] = await safe(backups.listByIdentifierAndStatePaged(app.id, backups.BACKUP_STATE_NORMAL, 1, 1));
let installationState, restoreConfig;
if (!error && results.length) {
installationState = ISTATE_PENDING_RESTORE;
// intentionally ignore any backupSite provided during restore by the user because the site may not have all the apps
restoreConfig = { backupId: results[0].id };
} else {
installationState = ISTATE_PENDING_INSTALL;
restoreConfig = null;
}
const task = {
args: { restoreConfig, skipDnsSetup: options.skipDnsSetup, overwriteDns: true, oldManifest: null },
values: {},
scheduleNow: false, // task will be scheduled by autoRestartTasks when platform is ready
requireNullTaskId: false // ignore existing stale taskId
};
log(`restoreApps: marking ${app.fqdn} as ${installationState} using restore config ${JSON.stringify(restoreConfig)}`);
const [addTaskError, taskId] = await safe(addTask(app.id, installationState, task, auditSource));
if (addTaskError) log(`restoreApps: error marking ${app.fqdn} for restore: ${JSON.stringify(addTaskError)}`);
else log(`restoreApps: marked ${app.id} as ${installationState} with taskId ${taskId}`);
}
}
async function configureApps(apps, options, auditSource) {
assert(Array.isArray(apps));
assert.strictEqual(typeof options, 'object');
assert.strictEqual(typeof auditSource, 'object');
apps = apps.filter(app => app.installationState !== ISTATE_ERROR); // remove errored apps. let them be 'repaired' by hand
apps = apps.filter(app => app.installationState !== ISTATE_PENDING_CONFIGURE); // safeguard against tasks being created non-stop if we crash on startup
const scheduleNow = !!options.scheduleNow;
for (const app of apps) {
log(`configureApps: marking ${app.fqdn} for reconfigure (scheduleNow: ${scheduleNow})`);
const task = {
args: {},
values: {},
scheduleNow,
requireNullTaskId: false // ignore existing stale taskId
};
const [addTaskError, taskId] = await safe(addTask(app.id, ISTATE_PENDING_CONFIGURE, task, auditSource));
if (addTaskError) log(`configureApps: error marking ${app.fqdn} for configure: ${JSON.stringify(addTaskError)}`);
else log(`configureApps: marked ${app.id} for re-configure with taskId ${taskId}`);
}
}
async function restartAppsUsingAddons(changedAddons, auditSource) {
assert(Array.isArray(changedAddons));
assert.strictEqual(typeof auditSource, 'object');
let apps = await list();
// TODO: This ends up restarting apps that have optional redis
apps = apps.filter(app => app.manifest.addons && _.intersection(Object.keys(app.manifest.addons), changedAddons).length !== 0);
apps = apps.filter(app => app.installationState !== ISTATE_ERROR); // remove errored apps. let them be 'repaired' by hand
apps = apps.filter(app => app.installationState !== ISTATE_PENDING_RESTART); // safeguard against tasks being created non-stop restart if we crash on startup
apps = apps.filter(app => app.runState !== RSTATE_STOPPED); // don't start stopped apps
for (const app of apps) {
log(`restartAppsUsingAddons: marking ${app.fqdn} for restart`);
const task = {
args: {},
values: { runState: RSTATE_RUNNING }
};
// stop apps before updating the databases because postgres will "lock" them preventing import
const [stopError] = await safe(docker.stopContainers(app.id));
if (stopError) log(`restartAppsUsingAddons: error stopping ${app.fqdn}`, stopError);
const [addTaskError, taskId] = await safe(addTask(app.id, ISTATE_PENDING_RESTART, task, auditSource));
if (addTaskError) log(`restartAppsUsingAddons: error marking ${app.fqdn} for restart: ${JSON.stringify(addTaskError)}`);
else log(`restartAppsUsingAddons: marked ${app.id} for restart with taskId ${taskId}`);
}
}
async function schedulePendingTasks(auditSource) {
assert.strictEqual(typeof auditSource, 'object');
log('schedulePendingTasks: scheduling app tasks');
const result = await list();
for (const app of result) {
if (!app.taskId) continue; // if not in any pending state, do nothing
log(`schedulePendingTasks: schedule task for ${app.fqdn} ${app.id}: state=${app.installationState},taskId=${app.taskId}`);
await safe(scheduleTask(app.id, app.installationState, app.taskId, auditSource), { debug: log }); // ignore error
}
}
export default {
canAccess,
isOperator,
accessLevel,
pickFields,
// database crud
add,
update,
setHealth,
del,
get,
getByIpAddress,
getByFqdn,
list,
listByUser,
// user actions
install,
unarchive,
uninstall,
archive,
setAccessRestriction,
setOperators,
setCrontab,
setUpstreamUri,
setLabel,
setIcon,
setTags,
setNotes,
setChecklistItem,
setMemoryLimit,
setCpuQuota,
setMounts,
setDevices,
setAutomaticBackup,
setAutomaticUpdate,
setReverseProxyConfig,
setCertificate,
setDebugMode,
setEnvironment,
setMailbox,
setInbox,
setTurn,
setRedis,
setLocation,
setStorage,
repair,
restore,
importApp,
exportApp,
clone,
updateApp,
backup,
listBackups,
updateBackup,
getBackupDownloadStream,
getTask,
getLogPaths,
getLogs,
appendLogLine,
start,
stop,
restart,
createExec,
startExec,
getExec,
checkManifest,
restoreApps,
configureApps,
schedulePendingTasks,
restartAppsUsingAddons,
getStorageDir,
getIcon,
getMemoryLimit,
getSchedulerConfig,
listEventlog,
downloadFile,
uploadFile,
writeConfig,
loadConfig,
canBackupApp,
attachUpdateInfoProperties,
PORT_TYPE_TCP,
PORT_TYPE_UDP,
// task codes - the installation state is now a misnomer (keep in sync in UI)
ISTATE_PENDING_INSTALL,
ISTATE_PENDING_CLONE,
ISTATE_PENDING_CONFIGURE,
ISTATE_PENDING_RECREATE_CONTAINER,
ISTATE_PENDING_LOCATION_CHANGE,
ISTATE_PENDING_SERVICES_CHANGE,
ISTATE_PENDING_DATA_DIR_MIGRATION,
ISTATE_PENDING_RESIZE,
ISTATE_PENDING_DEBUG,
ISTATE_PENDING_UNINSTALL,
ISTATE_PENDING_RESTORE,
ISTATE_PENDING_IMPORT,
ISTATE_PENDING_UPDATE,
ISTATE_PENDING_START,
ISTATE_PENDING_STOP,
ISTATE_PENDING_RESTART,
ISTATE_ERROR,
ISTATE_INSTALLED,
// run states
RSTATE_RUNNING,
RSTATE_STOPPED,
// health states (keep in sync in UI)
HEALTH_HEALTHY: 'healthy',
HEALTH_UNHEALTHY: 'unhealthy',
HEALTH_ERROR: 'error',
HEALTH_DEAD: 'dead',
// app access levels
ACCESS_LEVEL_ADMIN,
ACCESS_LEVEL_OPERATOR,
ACCESS_LEVEL_USER,
ACCESS_LEVEL_NONE,
// exported for testing
_checkForPortBindingConflict: checkForPortBindingConflict,
_validatePorts: validatePorts,
_validateAccessRestriction: validateAccessRestriction,
_validateUpstreamUri: validateUpstreamUri,
_validateLocations: validateLocations,
_parseCrontab: parseCrontab,
_clear: clear,
};