'use strict'; exports = module.exports = { getAutoupdatePattern, setAutoupdatePattern, getTimeZone, setTimeZone, getIPv6Config, setIPv6Config, getBackupPolicy, setBackupPolicy, getBackupConfig, setBackupConfig, getServicesConfig, setServicesConfig, getRegistryConfig, setRegistryConfig, getLanguage, setLanguage, getSysinfoConfig, setSysinfoConfig, getProfileConfig, setProfileConfig, getGhosts, setGhosts, provider, list, initCache, // these values come from the cache apiServerOrigin, webServerOrigin, consoleServerOrigin, dashboardDomain, setDashboardLocation, setMailLocation, mailFqdn, mailDomain, dashboardOrigin, dashboardFqdn, isDemo, get, set, getBlob, setBlob, // booleans. if you add an entry here, be sure to fix list() DYNAMIC_DNS_KEY: 'dynamic_dns', DEMO_KEY: 'demo', // json. if you add an entry here, be sure to fix list() BACKUP_CONFIG_KEY: 'backup_config', BACKUP_POLICY_KEY: 'backup_policy', SERVICES_CONFIG_KEY: 'services_config', EXTERNAL_LDAP_KEY: 'external_ldap_config', DIRECTORY_SERVER_KEY: 'user_directory_config', REGISTRY_CONFIG_KEY: 'registry_config', SYSINFO_CONFIG_KEY: 'sysinfo_config', // misnomer: ipv4 config SUPPORT_CONFIG_KEY: 'support_config', PROFILE_CONFIG_KEY: 'profile_config', GHOSTS_CONFIG_KEY: 'ghosts_config', REVERSE_PROXY_CONFIG_KEY: 'reverseproxy_config', IPV6_CONFIG_KEY: 'ipv6_config', // strings AUTOUPDATE_PATTERN_KEY: 'autoupdate_pattern', TIME_ZONE_KEY: 'time_zone', OIDC_COOKIE_SECRET_KEY: 'cookie_secret', CLOUDRON_NAME_KEY: 'cloudron_name', LANGUAGE_KEY: 'language', CLOUDRON_ID_KEY: 'cloudron_id', APPSTORE_API_TOKEN_KEY: 'appstore_api_token', APPSTORE_WEB_TOKEN_KEY: 'appstore_web_token', FIREWALL_BLOCKLIST_KEY: 'firewall_blocklist', TRUSTED_IPS_KEY: 'trusted_ips_key', API_SERVER_ORIGIN_KEY: 'api_server_origin', WEB_SERVER_ORIGIN_KEY: 'web_server_origin', CONSOLE_SERVER_ORIGIN_KEY: 'console_server_origin', DASHBOARD_DOMAIN_KEY: 'admin_domain', DASHBOARD_FQDN_KEY: 'admin_fqdn', MAIL_DOMAIN_KEY: 'mail_domain', MAIL_FQDN_KEY: 'mail_fqdn', PROVIDER_KEY: 'provider', FOOTER_KEY: 'footer', // blobs CLOUDRON_AVATAR_KEY: 'cloudron_avatar', // testing _setApiServerOrigin: setApiServerOrigin, _clear: clear, _set: set }; const assert = require('assert'), backups = require('./backups.js'), BoxError = require('./boxerror.js'), constants = require('./constants.js'), cron = require('./cron.js'), CronJob = require('cron').CronJob, database = require('./database.js'), debug = require('debug')('box:settings'), docker = require('./docker.js'), moment = require('moment-timezone'), mounts = require('./mounts.js'), paths = require('./paths.js'), safe = require('safetydance'), sysinfo = require('./sysinfo.js'), tokens = require('./tokens.js'), translation = require('./translation.js'), users = require('./users.js'), _ = require('underscore'); const SETTINGS_FIELDS = [ 'name', 'value' ].join(','); const SETTINGS_BLOB_FIELDS = [ 'name', 'valueBlob' ].join(','); const gDefaults = (function () { const result = { }; result[exports.AUTOUPDATE_PATTERN_KEY] = cron.DEFAULT_AUTOUPDATE_PATTERN; result[exports.TIME_ZONE_KEY] = 'UTC'; result[exports.IPV6_CONFIG_KEY] = { provider: 'noop' }; result[exports.LANGUAGE_KEY] = 'en'; result[exports.BACKUP_CONFIG_KEY] = { provider: 'filesystem', backupFolder: paths.DEFAULT_BACKUP_DIR, format: 'tgz', encryption: null, }; result[exports.BACKUP_POLICY_KEY] = { retention: { keepWithinSecs: 2 * 24 * 60 * 60 }, // 2 days schedule: '00 00 23 * * *' // every day at 11pm }; result[exports.SERVICES_CONFIG_KEY] = {}; result[exports.REGISTRY_CONFIG_KEY] = { provider: 'noop' }; result[exports.SYSINFO_CONFIG_KEY] = { provider: 'generic' }; result[exports.PROFILE_CONFIG_KEY] = { lockUserProfiles: false, mandatory2FA: false }; result[exports.DASHBOARD_DOMAIN_KEY] = ''; result[exports.DASHBOARD_FQDN_KEY] = ''; result[exports.MAIL_DOMAIN_KEY] = ''; result[exports.MAIL_FQDN_KEY] = ''; result[exports.API_SERVER_ORIGIN_KEY] = 'https://api.cloudron.io'; result[exports.WEB_SERVER_ORIGIN_KEY] = 'https://cloudron.io'; result[exports.CONSOLE_SERVER_ORIGIN_KEY] = 'https://console.cloudron.io'; result[exports.DEMO_KEY] = false; result[exports.GHOSTS_CONFIG_KEY] = {}; return result; })(); let gCache = {}; function notifyChange(key, value) { assert.strictEqual(typeof key, 'string'); // value is a variant cron.handleSettingsChanged(key, value); } async function get(key) { assert.strictEqual(typeof key, 'string'); const result = await database.query(`SELECT ${SETTINGS_FIELDS} FROM settings WHERE name = ?`, [ key ]); if (result.length === 0) return null; // can't return the default value here because we might need to massage/json parse the result return result[0].value; } async function set(key, value) { assert.strictEqual(typeof key, 'string'); assert(value === null || typeof value === 'string'); await database.query('INSERT INTO settings (name, value) VALUES (?, ?) ON DUPLICATE KEY UPDATE value=VALUES(value)', [ key, value ]); // don't rely on affectedRows here since it gives 2 } async function getBlob(key) { assert.strictEqual(typeof key, 'string'); const result = await database.query(`SELECT ${SETTINGS_BLOB_FIELDS} FROM settings WHERE name = ?`, [ key ]); if (result.length === 0) return null; return result[0].valueBlob; } async function setBlob(key, value) { assert.strictEqual(typeof key, 'string'); assert(value === null || Buffer.isBuffer(value)); await database.query('INSERT INTO settings (name, valueBlob) VALUES (?, ?) ON DUPLICATE KEY UPDATE valueBlob=VALUES(valueBlob)', [ key, value ]); // don't rely on affectedRows here since it gives 2 } async function clear() { await database.query('DELETE FROM settings'); } async function setAutoupdatePattern(pattern) { assert.strictEqual(typeof pattern, 'string'); if (pattern !== constants.AUTOUPDATE_PATTERN_NEVER) { // check if pattern is valid const job = safe.safeCall(function () { return new CronJob(pattern); }); if (!job) throw new BoxError(BoxError.BAD_FIELD, 'Invalid pattern'); } await set(exports.AUTOUPDATE_PATTERN_KEY, pattern); notifyChange(exports.AUTOUPDATE_PATTERN_KEY, pattern); } async function getAutoupdatePattern() { const pattern = await get(exports.AUTOUPDATE_PATTERN_KEY); if (pattern === null) return gDefaults[exports.AUTOUPDATE_PATTERN_KEY]; return pattern; } async function setTimeZone(tz) { assert.strictEqual(typeof tz, 'string'); if (moment.tz.names().indexOf(tz) === -1) throw new BoxError(BoxError.BAD_FIELD, 'Bad timeZone'); await set(exports.TIME_ZONE_KEY, tz); notifyChange(exports.TIME_ZONE_KEY, tz); } async function getTimeZone() { const tz = await get(exports.TIME_ZONE_KEY); if (tz === null) return gDefaults[exports.TIME_ZONE_KEY]; return tz; } async function getIPv6Config() { const value = await get(exports.IPV6_CONFIG_KEY); if (value === null) return gDefaults[exports.IPV6_CONFIG_KEY]; return JSON.parse(value); } async function setIPv6Config(ipv6Config) { assert.strictEqual(typeof ipv6Config, 'object'); if (isDemo()) throw new BoxError(BoxError.BAD_FIELD, 'Not allowed in demo mode'); const error = await sysinfo.testIPv6Config(ipv6Config); if (error) throw error; await set(exports.IPV6_CONFIG_KEY, JSON.stringify(ipv6Config)); notifyChange(exports.IPV6_CONFIG_KEY, ipv6Config); } async function getBackupPolicy() { const result = await get(exports.BACKUP_POLICY_KEY); if (result === null) return gDefaults[exports.BACKUP_POLICY_KEY]; return JSON.parse(result); } async function setBackupPolicy(policy) { assert.strictEqual(typeof policy, 'object'); const error = await backups.validatePolicy(policy); if (error) throw error; await set(exports.BACKUP_POLICY_KEY, JSON.stringify(policy)); notifyChange(exports.BACKUP_POLICY_KEY, policy); } async function getBackupConfig() { const value = await get(exports.BACKUP_CONFIG_KEY); if (value === null) return gDefaults[exports.BACKUP_CONFIG_KEY]; const backupConfig = JSON.parse(value); // { provider, token, password, region, prefix, bucket } return backupConfig; } async function setBackupConfig(backupConfig) { assert.strictEqual(typeof backupConfig, 'object'); const oldConfig = await getBackupConfig(); backups.injectPrivateFields(backupConfig, oldConfig); if (mounts.isManagedProvider(backupConfig.provider)) { let error = mounts.validateMountOptions(backupConfig.provider, backupConfig.mountOptions); if (error) throw error; [error] = await safe(mounts.tryAddMount(mounts.mountObjectFromBackupConfig(backupConfig), { timeout: 10 })); // 10 seconds if (error) { if (mounts.isManagedProvider(oldConfig.provider)) { // put back the old mount configuration debug('setBackupConfig: rolling back to previous mount configuration'); await safe(mounts.tryAddMount(mounts.mountObjectFromBackupConfig(oldConfig), { timeout: 10 })); } throw error; } } const error = await backups.testConfig(backupConfig); if (error) throw error; if ('password' in backupConfig) { // user set password const error = await backups.validateEncryptionPassword(backupConfig.password); if (error) throw error; backupConfig.encryption = backups.generateEncryptionKeysSync(backupConfig.password); delete backupConfig.password; } // if any of these changes, we have to clear the cache if (!_.isEqual(_.omit(backupConfig, 'limits'), _.omit(oldConfig, 'limits'))) { debug('setBackupConfig: clearing backup cache'); backups.cleanupCacheFilesSync(); } await set(exports.BACKUP_CONFIG_KEY, JSON.stringify(backupConfig)); if (mounts.isManagedProvider(oldConfig.provider) && !mounts.isManagedProvider(backupConfig.provider)) { debug('setBackupConfig: removing old backup mount point'); await safe(mounts.removeMount(mounts.mountObjectFromBackupConfig(oldConfig))); } notifyChange(exports.BACKUP_CONFIG_KEY, backupConfig); } async function getServicesConfig() { const value = await get(exports.SERVICES_CONFIG_KEY); if (value === null) return gDefaults[exports.SERVICES_CONFIG_KEY]; return JSON.parse(value); } async function setServicesConfig(platformConfig) { await set(exports.SERVICES_CONFIG_KEY, JSON.stringify(platformConfig)); notifyChange(exports.SERVICES_CONFIG_KEY, platformConfig); } async function getRegistryConfig() { const value = await get(exports.REGISTRY_CONFIG_KEY); if (value === null) return gDefaults[exports.REGISTRY_CONFIG_KEY]; return JSON.parse(value); } async function setRegistryConfig(registryConfig) { assert.strictEqual(typeof registryConfig, 'object'); const currentConfig = await getRegistryConfig(); docker.injectPrivateFields(registryConfig, currentConfig); await docker.testRegistryConfig(registryConfig); await set(exports.REGISTRY_CONFIG_KEY, JSON.stringify(registryConfig)); notifyChange(exports.REGISTRY_CONFIG_KEY, registryConfig); } async function getSysinfoConfig() { const value = await get(exports.SYSINFO_CONFIG_KEY); if (value === null) return gDefaults[exports.SYSINFO_CONFIG_KEY]; return JSON.parse(value); } async function setSysinfoConfig(sysinfoConfig) { assert.strictEqual(typeof sysinfoConfig, 'object'); if (isDemo()) throw new BoxError(BoxError.BAD_FIELD, 'Not allowed in demo mode'); const error = await sysinfo.testIPv4Config(sysinfoConfig); if (error) throw error; await set(exports.SYSINFO_CONFIG_KEY, JSON.stringify(sysinfoConfig)); notifyChange(exports.SYSINFO_CONFIG_KEY, sysinfoConfig); } async function getProfileConfig() { const value = await get(exports.PROFILE_CONFIG_KEY); if (value === null) return gDefaults[exports.PROFILE_CONFIG_KEY]; return JSON.parse(value); } async function setProfileConfig(directoryConfig) { assert.strictEqual(typeof directoryConfig, 'object'); if (isDemo()) throw new BoxError(BoxError.BAD_FIELD, 'Not allowed in demo mode'); const oldConfig = await getProfileConfig(); await set(exports.PROFILE_CONFIG_KEY, JSON.stringify(directoryConfig)); if (directoryConfig.mandatory2FA && !oldConfig.mandatory2FA) { debug('setProfileConfig: logging out non-2FA users to enforce 2FA'); const allUsers = await users.list(); for (const user of allUsers) { if (!user.twoFactorAuthenticationEnabled) await tokens.delByUserIdAndType(user.id, tokens.ID_WEBADMIN); } } notifyChange(exports.PROFILE_CONFIG_KEY, directoryConfig); } async function getGhosts() { const value = await get(exports.GHOSTS_CONFIG_KEY); if (value === null) return gDefaults[exports.GHOSTS_CONFIG_KEY]; return JSON.parse(value); } async function setGhosts(ghosts) { assert.strictEqual(typeof ghosts, 'object'); await set(exports.GHOSTS_CONFIG_KEY, JSON.stringify(ghosts)); notifyChange(exports.GHOSTS_CONFIG_KEY, ghosts); } async function getLanguage() { const value = await get(exports.LANGUAGE_KEY); if (value === null) return gDefaults[exports.LANGUAGE_KEY]; return value; } async function setLanguage(language) { assert.strictEqual(typeof language, 'string'); const languages = await translation.getLanguages(); if (languages.indexOf(language) === -1) throw new BoxError(BoxError.NOT_FOUND, 'Language not found'); await set(exports.LANGUAGE_KEY, language); notifyChange(exports.LANGUAGE_KEY, language); } async function list() { const settings = await database.query(`SELECT ${SETTINGS_FIELDS} FROM settings WHERE value IS NOT NULL ORDER BY name`); const result = Object.assign({}, gDefaults); settings.forEach(function (setting) { result[setting.name] = setting.value; }); // convert booleans result[exports.DEMO_KEY] = !!result[exports.DEMO_KEY]; // convert JSON objects [exports.BACKUP_POLICY_KEY, exports.BACKUP_CONFIG_KEY, exports.IPV6_CONFIG_KEY, exports.PROFILE_CONFIG_KEY, exports.SERVICES_CONFIG_KEY, exports.REGISTRY_CONFIG_KEY, exports.SYSINFO_CONFIG_KEY, exports.REVERSE_PROXY_CONFIG_KEY ].forEach(function (key) { result[key] = typeof result[key] === 'object' ? result[key] : safe.JSON.parse(result[key]); }); return result; } async function initCache() { debug('initCache: pre-load settings'); const allSettings = await list(); const provider = safe.fs.readFileSync(paths.PROVIDER_FILE, 'utf8'); gCache = { apiServerOrigin: allSettings[exports.API_SERVER_ORIGIN_KEY], webServerOrigin: allSettings[exports.WEB_SERVER_ORIGIN_KEY], consoleServerOrigin: allSettings[exports.CONSOLE_SERVER_ORIGIN_KEY], dashboardDomain: allSettings[exports.DASHBOARD_DOMAIN_KEY], dashboardFqdn: allSettings[exports.DASHBOARD_FQDN_KEY], mailDomain: allSettings[exports.MAIL_DOMAIN_KEY], mailFqdn: allSettings[exports.MAIL_FQDN_KEY], isDemo: allSettings[exports.DEMO_KEY], provider: provider ? provider.trim() : 'generic' }; } // this is together so we can do this in a transaction later async function setDashboardLocation(dashboardDomain, dashboardFqdn) { assert.strictEqual(typeof dashboardDomain, 'string'); assert.strictEqual(typeof dashboardFqdn, 'string'); await set(exports.DASHBOARD_DOMAIN_KEY, dashboardDomain); await set(exports.DASHBOARD_FQDN_KEY, dashboardFqdn); gCache.dashboardDomain = dashboardDomain; gCache.dashboardFqdn = dashboardFqdn; } async function setMailLocation(mailDomain, mailFqdn) { assert.strictEqual(typeof mailDomain, 'string'); assert.strictEqual(typeof mailFqdn, 'string'); await set(exports.MAIL_DOMAIN_KEY, mailDomain); await set(exports.MAIL_FQDN_KEY, mailFqdn); gCache.mailDomain = mailDomain; gCache.mailFqdn = mailFqdn; } async function setApiServerOrigin(origin) { assert.strictEqual(typeof origin, 'string'); await set(exports.API_SERVER_ORIGIN_KEY, origin); gCache.apiServerOrigin = origin; notifyChange(exports.API_SERVER_ORIGIN_KEY, origin); } function provider() { return gCache.provider; } function apiServerOrigin() { return gCache.apiServerOrigin; } function webServerOrigin() { return gCache.webServerOrigin; } function consoleServerOrigin() { return gCache.consoleServerOrigin; } function dashboardDomain() { return gCache.dashboardDomain; } function dashboardFqdn() { return gCache.dashboardFqdn; } function isDemo() { return gCache.isDemo; } function mailDomain() { return gCache.mailDomain; } function mailFqdn() { return gCache.mailFqdn; } function dashboardOrigin() { return 'https://' + dashboardFqdn(); }