diff --git a/package-lock.json b/package-lock.json index d1af2c74..d1ccb3cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "balena-supervisor", - "version": "9.2.3", + "version": "9.2.6", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -234,6 +234,15 @@ "integrity": "sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==", "dev": true }, + "@types/mkdirp": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@types/mkdirp/-/mkdirp-0.5.2.tgz", + "integrity": "sha512-U5icWpv7YnZYGsN4/cmh3WD2onMY0aJIiTE6+51TwJCttdHvtCYmkBNOobHlXwrJRL0nkH9jH4kD+1FAdMN4Tg==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/mz": { "version": "0.0.32", "resolved": "https://registry.npmjs.org/@types/mz/-/mz-0.0.32.tgz", @@ -1554,7 +1563,7 @@ }, "cacache": { "version": "10.0.4", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-10.0.4.tgz", + "resolved": "http://registry.npmjs.org/cacache/-/cacache-10.0.4.tgz", "integrity": "sha512-Dph0MzuH+rTQzGPNT9fAnrPmMmjKfST6trxJeK7NQuHRaVw24VzPRWTmg9MpcwOVQZO0E1FBICUlFeNaKPIfHA==", "dev": true, "requires": { @@ -2199,7 +2208,7 @@ }, "dbus-native": { "version": "0.2.5", - "resolved": "https://registry.npmjs.org/dbus-native/-/dbus-native-0.2.5.tgz", + "resolved": "http://registry.npmjs.org/dbus-native/-/dbus-native-0.2.5.tgz", "integrity": "sha512-ocxMKCV7QdiNhzhFSeEMhj258OGtvpANSb3oWGiotmI5h1ZIse0TMPcSLiXSpqvbYvQz2Y5RsYPMNYLWhg9eBw==", "dev": true, "requires": { @@ -2354,7 +2363,7 @@ "dependencies": { "globby": { "version": "6.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-6.1.0.tgz", + "resolved": "http://registry.npmjs.org/globby/-/globby-6.1.0.tgz", "integrity": "sha1-9abXDoOV4hyFj7BInWTfAkJNUGw=", "dev": true, "requires": { @@ -2617,7 +2626,7 @@ }, "string_decoder": { "version": "0.10.31", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "resolved": "http://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=", "dev": true } @@ -6803,7 +6812,7 @@ }, "next-tick": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz", + "resolved": "http://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz", "integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw=", "dev": true }, @@ -7153,7 +7162,7 @@ }, "os-homedir": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "resolved": "http://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=" }, "os-locale": { @@ -7203,7 +7212,7 @@ }, "os-tmpdir": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "resolved": "http://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=" }, "osenv": { @@ -7352,7 +7361,7 @@ }, "path-is-absolute": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "resolved": "http://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" }, "path-is-inside": { @@ -8725,7 +8734,7 @@ }, "source-map": { "version": "0.2.0", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.2.0.tgz", + "resolved": "http://registry.npmjs.org/source-map/-/source-map-0.2.0.tgz", "integrity": "sha1-2rc/vPwrqBm03gO9b26qSBZLP50=", "dev": true, "optional": true, @@ -8937,7 +8946,7 @@ }, "stream-browserify": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.1.tgz", + "resolved": "http://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.1.tgz", "integrity": "sha1-ZiZu5fm9uZQKTkUUyvtDu3Hlyds=", "dev": true, "requires": { @@ -9017,7 +9026,7 @@ }, "string_decoder": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "resolved": "http://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "requires": { "safe-buffer": "~5.1.0" diff --git a/package.json b/package.json index 2d02a040..4dbe1815 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ }, "devDependencies": { "@types/bluebird": "^3.5.25", + "@types/common-tags": "^1.8.0", "@types/dockerode": "^2.5.10", "@types/event-stream": "^3.3.34", "@types/express": "^4.11.1", @@ -37,6 +38,7 @@ "@types/lockfile": "^1.0.0", "@types/lodash": "^4.14.119", "@types/memoizee": "^0.4.2", + "@types/mkdirp": "^0.5.2", "@types/mz": "0.0.32", "@types/node": "^10.12.17", "@types/request": "^2.48.1", @@ -50,6 +52,7 @@ "chai-events": "0.0.1", "coffee-loader": "^0.9.0", "coffeescript": "^1.12.7", + "common-tags": "^1.8.0", "copy-webpack-plugin": "^4.6.0", "dbus-native": "^0.2.5", "deep-object-diff": "^1.1.0", diff --git a/src/api-binder.coffee b/src/api-binder.coffee index 89655faf..064ddd75 100644 --- a/src/api-binder.coffee +++ b/src/api-binder.coffee @@ -329,13 +329,13 @@ module.exports = class APIBinder # Creates the necessary config vars in the API to match the current device state, # without overwriting any variables that are already set. _reportInitialEnv: (apiEndpoint) => + defaultConfig = @deviceState.deviceConfig.getDefaults() Promise.join( @deviceState.getCurrentForComparison() @getTargetState().then (targetState) => @deviceState.deviceConfig.formatConfigKeys(targetState.local.config) - @deviceState.deviceConfig.getDefaults() @config.get('deviceId') - (currentState, targetConfig, defaultConfig, deviceId) => + (currentState, targetConfig, deviceId) => currentConfig = currentState.local.config Promise.mapSeries _.toPairs(currentConfig), ([ key, value ]) => # We want to disable local mode when joining a cloud diff --git a/src/application-manager.d.ts b/src/application-manager.d.ts index a4575943..a1fb985b 100644 --- a/src/application-manager.d.ts +++ b/src/application-manager.d.ts @@ -8,7 +8,7 @@ import { EventTracker } from './event-tracker'; import Images = require('./compose/images'); import ServiceManager = require('./compose/service-manager'); -import DB = require('./db'); +import DB from './db'; import { Service } from './compose/service'; @@ -51,7 +51,9 @@ export class ApplicationManager extends EventEmitter { opts: Options, ): Bluebird; - public getStatus(): Promise; + // FIXME: Type this properly as it's some mutant state between + // the state endpoint and the ApplicationManager internals + public getStatus(): Promise>; public serviceNameFromId(serviceId: number): Bluebird; } diff --git a/src/config/index.ts b/src/config/index.ts index 8b30553e..15f8ea6c 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -6,11 +6,11 @@ import { generateUniqueKey } from 'resin-register-device'; import ConfigJsonConfigBackend from './configJson'; -import { ConfigProviderFunctions, createProviderFunctions } from './functions'; import * as constants from '../lib/constants'; import { ConfigMap, ConfigSchema, ConfigValue } from '../lib/types'; +import { ConfigProviderFunctions, createProviderFunctions } from './functions'; -import DB = require('../db'); +import DB from '../db'; interface ConfigOpts { db: DB; @@ -215,7 +215,7 @@ class Config extends EventEmitter { return setValuesInTransaction(trx).return(); } else { return this.db - .transaction(tx => { + .transaction((tx: Transaction) => { return setValuesInTransaction(tx); }) .return(); diff --git a/src/db.ts b/src/db.ts index 2291d026..57366e23 100644 --- a/src/db.ts +++ b/src/db.ts @@ -10,7 +10,9 @@ interface DBOpts { type DBTransactionCallback = (trx: Knex.Transaction) => void; -class DB { +export type Transaction = Knex.Transaction; + +export class DB { private databasePath: string; private knex: Knex; @@ -65,4 +67,4 @@ class DB { } } -export = DB; +export default DB; diff --git a/src/device-config.coffee b/src/device-config.coffee deleted file mode 100644 index 33260a77..00000000 --- a/src/device-config.coffee +++ /dev/null @@ -1,235 +0,0 @@ -Promise = require 'bluebird' -_ = require 'lodash' - -systemd = require './lib/systemd' -{ checkTruthy, checkInt } = require './lib/validation' -{ UnitNotLoadedError } = require './lib/errors' -configUtils = require './config/utils' - -vpnServiceName = 'openvpn-resin' - -module.exports = class DeviceConfig - constructor: ({ @db, @config, @logger }) -> - @rebootRequired = false - @configKeys = { - appUpdatePollInterval: { envVarName: 'SUPERVISOR_POLL_INTERVAL', varType: 'int', defaultValue: '60000' } - localMode: { envVarName: 'SUPERVISOR_LOCAL_MODE', varType: 'bool', defaultValue: 'false' } - connectivityCheckEnabled: { envVarName: 'SUPERVISOR_CONNECTIVITY_CHECK', varType: 'bool', defaultValue: 'true' } - loggingEnabled: { envVarName: 'SUPERVISOR_LOG_CONTROL', varType: 'bool', defaultValue: 'true' } - delta: { envVarName: 'SUPERVISOR_DELTA', varType: 'bool', defaultValue: 'false' } - deltaRequestTimeout: { envVarName: 'SUPERVISOR_DELTA_REQUEST_TIMEOUT', varType: 'int', defaultValue: '30000' } - deltaApplyTimeout: { envVarName: 'SUPERVISOR_DELTA_APPLY_TIMEOUT', varType: 'int', defaultValue: '' } - deltaRetryCount: { envVarName: 'SUPERVISOR_DELTA_RETRY_COUNT', varType: 'int', defaultValue: '30' } - deltaRetryInterval: { envVarName: 'SUPERVISOR_DELTA_RETRY_INTERVAL', varType: 'int', defaultValue: '10000' } - deltaVersion: { envVarName: 'SUPERVISOR_DELTA_VERSION', varType: 'int', defaultValue: '2' } - lockOverride: { envVarName: 'SUPERVISOR_OVERRIDE_LOCK', varType: 'bool', defaultValue: 'false' } - persistentLogging: { envVarName: 'SUPERVISOR_PERSISTENT_LOGGING', varType: 'bool', defaultValue: 'false', rebootRequired: true } - } - @validKeys = [ - 'SUPERVISOR_VPN_CONTROL', - 'OVERRIDE_LOCK', - ].concat(_.map(@configKeys, 'envVarName')) - @actionExecutors = { - changeConfig: (step) => - @logger.logConfigChange(step.humanReadableTarget) - @config.set(step.target) - .then => - @logger.logConfigChange(step.humanReadableTarget, { success: true }) - if step.rebootRequired - @rebootRequired = true - .tapCatch (err) => - @logger.logConfigChange(step.humanReadableTarget, { err }) - setVPNEnabled: (step, { initial = false } = {}) => - logValue = { SUPERVISOR_VPN_CONTROL: step.target } - if !initial - @logger.logConfigChange(logValue) - @setVPNEnabled(step.target) - .then => - if !initial - @logger.logConfigChange(logValue, { success: true }) - .tapCatch (err) => - @logger.logConfigChange(logValue, { err }) - setBootConfig: (step) => - @getConfigBackend() - .then (configBackend ) => - @setBootConfig(configBackend, step.target) - } - @validActions = _.keys(@actionExecutors) - @configBackend = null - - getConfigBackend: => - if @configBackend? - Promise.resolve(@configBackend) - else - @config.get('deviceType').then (deviceType) => - @configBackend = configUtils.getConfigBackend(deviceType) - return @configBackend - - setTarget: (target, trx) => - db = trx ? @db.models.bind(@db) - @formatConfigKeys(target) - .then (formattedTarget) -> - confToUpdate = { - targetValues: JSON.stringify(formattedTarget) - } - db('deviceConfig').update(confToUpdate) - - getTarget: ({ initial = false } = {}) => - Promise.all([ - @config.get('unmanaged') - @db.models('deviceConfig').select('targetValues') - ]) - .then ([unmanaged, [ devConfig ]]) => - conf = JSON.parse(devConfig.targetValues) - if initial or !conf.SUPERVISOR_VPN_CONTROL? - conf.SUPERVISOR_VPN_CONTROL = 'true' - if unmanaged and !conf.SUPERVISOR_LOCAL_MODE? - conf.SUPERVISOR_LOCAL_MODE = 'true' - for own k, { envVarName, defaultValue } of @configKeys - conf[envVarName] ?= defaultValue - return conf - - getCurrent: => - Promise.all [ - @config.getMany([ 'deviceType' ].concat(_.keys(@configKeys))) - @getConfigBackend() - ] - .then ([ conf, configBackend ]) => - Promise.join( - @getVPNEnabled() - @getBootConfig(configBackend) - (vpnStatus, bootConfig) => - currentConf = { - SUPERVISOR_VPN_CONTROL: (vpnStatus ? 'true').toString() - } - for own key, { envVarName } of @configKeys - currentConf[envVarName] = (conf[key] ? '').toString() - return _.assign(currentConf, bootConfig) - ) - - formatConfigKeys: (conf) => - @getConfigBackend() - .then (configBackend) => - configUtils.formatConfigKeys(configBackend, @validKeys, conf) - - getDefaults: => - Promise.try => - return _.extend({ - SUPERVISOR_VPN_CONTROL: 'true' - }, _.mapValues(_.mapKeys(@configKeys, 'envVarName'), 'defaultValue')) - - bootConfigChangeRequired: (configBackend, current, target) => - targetBootConfig = configUtils.envToBootConfig(configBackend, target) - currentBootConfig = configUtils.envToBootConfig(configBackend, current) - - if !_.isEqual(currentBootConfig, targetBootConfig) - _.each targetBootConfig, (value, key) => - if not configBackend.isSupportedConfig(key) - if currentBootConfig[key] != value - err = "Attempt to change blacklisted config value #{key}" - @logger.logSystemMessage(err, { error: err }, 'Apply boot config error') - throw new Error(err) - return true - return false - - getRequiredSteps: (currentState, targetState) => - current = _.clone(currentState.local?.config ? {}) - target = _.clone(targetState.local?.config ? {}) - steps = [] - Promise.all [ - @config.getMany([ 'deviceType', 'unmanaged' ]) - @getConfigBackend() - ] - .then ([{ deviceType, unmanaged }, configBackend ]) => - configChanges = {} - humanReadableConfigChanges = {} - match = { - 'bool': (a, b) -> - checkTruthy(a) == checkTruthy(b) - 'int': (a, b) -> - checkInt(a) == checkInt(b) - } - # If the legacy lock override is used, place it as the new variable - if checkTruthy(target['OVERRIDE_LOCK']) - target['SUPERVISOR_OVERRIDE_LOCK'] = target['OVERRIDE_LOCK'] - reboot = false - for own key, { envVarName, varType, rebootRequired } of @configKeys - if !match[varType](current[envVarName], target[envVarName]) - configChanges[key] = target[envVarName] - humanReadableConfigChanges[envVarName] = target[envVarName] - reboot = reboot || (rebootRequired ? false) - if !_.isEmpty(configChanges) - steps.push({ - action: 'changeConfig' - target: configChanges - humanReadableTarget: humanReadableConfigChanges - rebootRequired: reboot - }) - return - - # Check if we need to perform special case actions for the VPN - if !checkTruthy(unmanaged) && - !_.isEmpty(target['SUPERVISOR_VPN_CONTROL']) && - @checkBoolChanged(current, target, 'SUPERVISOR_VPN_CONTROL') - steps.push({ - action: 'setVPNEnabled' - target: target['SUPERVISOR_VPN_CONTROL'] - }) - - # Do we need to change the boot config? - if @bootConfigChangeRequired(configBackend, current, target) - steps.push({ - action: 'setBootConfig' - target - }) - - if !_.isEmpty(steps) - return - if @rebootRequired - steps.push({ - action: 'reboot' - }) - return - .return(steps) - - executeStepAction: (step, opts) => - @actionExecutors[step.action](step, opts) - - getBootConfig: (configBackend) -> - Promise.try -> - if !configBackend? - return {} - configBackend.getBootConfig() - .then (config) -> - return configUtils.bootConfigToEnv(configBackend, config) - - setBootConfig: (configBackend, target) => - Promise.try => - if !configBackend? - return false - conf = configUtils.envToBootConfig(configBackend, target) - @logger.logSystemMessage("Applying boot config: #{JSON.stringify(conf)}", {}, 'Apply boot config in progress') - - configBackend.setBootConfig(conf) - .then => - @logger.logSystemMessage("Applied boot config: #{JSON.stringify(conf)}", {}, 'Apply boot config success') - @rebootRequired = true - return true - .tapCatch (err) => - @logger.logSystemMessage("Error setting boot config: #{err}", { error: err }, 'Apply boot config error') - - getVPNEnabled: -> - systemd.serviceActiveState(vpnServiceName) - .then (activeState) -> - return activeState not in [ 'inactive', 'deactivating' ] - .catchReturn(UnitNotLoadedError, null) - - setVPNEnabled: (val) -> - enable = checkTruthy(val) ? true - if enable - systemd.startService(vpnServiceName) - else - systemd.stopService(vpnServiceName) - - checkBoolChanged: (current, target, key) -> - checkTruthy(current[key]) != checkTruthy(target[key]) diff --git a/src/device-config.ts b/src/device-config.ts new file mode 100644 index 00000000..92fdb53a --- /dev/null +++ b/src/device-config.ts @@ -0,0 +1,518 @@ +import * as _ from 'lodash'; + +import Config = require('./config'); +import Database, { Transaction } from './db'; +import Logger from './logger'; + +import { DeviceConfigBackend } from './config/backend'; +import * as configUtils from './config/utils'; +import { UnitNotLoadedError } from './lib/errors'; +import * as systemd from './lib/systemd'; +import { EnvVarObject } from './lib/types'; +import { checkInt, checkTruthy } from './lib/validation'; +import { DeviceApplicationState } from './types/state'; + +const vpnServiceName = 'openvpn-resin'; + +interface DeviceConfigConstructOpts { + db: Database; + config: Config; + logger: Logger; +} + +interface ConfigOption { + envVarName: string; + varType: string; + defaultValue: string; + rebootRequired?: boolean; +} + +interface ConfigStep { + // TODO: This is a bit of a mess, the DeviceConfig class shouldn't + // know that the reboot action exists as it is implemented by + // DeviceState. Fix this weird circular dependency + action: keyof DeviceActionExecutors | 'reboot'; + humanReadableTarget?: Dictionary; + target?: string | Dictionary; + rebootRequired?: boolean; +} + +interface DeviceActionExecutorOpts { + initial?: boolean; +} + +type DeviceActionExecutorFn = ( + step: ConfigStep, + opts?: DeviceActionExecutorOpts, +) => Promise; + +interface DeviceActionExecutors { + changeConfig: DeviceActionExecutorFn; + setVPNEnabled: DeviceActionExecutorFn; + setBootConfig: DeviceActionExecutorFn; +} + +export class DeviceConfig { + private db: Database; + private config: Config; + private logger: Logger; + private rebootRequired = false; + private actionExecutors: DeviceActionExecutors; + private configBackend: DeviceConfigBackend | null = null; + + private static configKeys: Dictionary = { + appUpdatePollInterval: { + envVarName: 'SUPERVISOR_POLL_INTERVAL', + varType: 'int', + defaultValue: '60000', + }, + localMode: { + envVarName: 'SUPERVISOR_LOCAL_MODE', + varType: 'bool', + defaultValue: 'false', + }, + connectivityCheckEnabled: { + envVarName: 'SUPERVISOR_CONNECTIVITY_CHECK', + varType: 'bool', + defaultValue: 'true', + }, + loggingEnabled: { + envVarName: 'SUPERVISOR_LOG_CONTROL', + varType: 'bool', + defaultValue: 'true', + }, + delta: { + envVarName: 'SUPERVISOR_DELTA', + varType: 'bool', + defaultValue: 'false', + }, + deltaRequestTimeout: { + envVarName: 'SUPERVISOR_DELTA_REQUEST_TIMEOUT', + varType: 'int', + defaultValue: '30000', + }, + deltaApplyTimeout: { + envVarName: 'SUPERVISOR_DELTA_APPLY_TIMEOUT', + varType: 'int', + defaultValue: '', + }, + deltaRetryCount: { + envVarName: 'SUPERVISOR_DELTA_RETRY_COUNT', + varType: 'int', + defaultValue: '30', + }, + deltaRetryInterval: { + envVarName: 'SUPERVISOR_DELTA_RETRY_INTERVAL', + varType: 'int', + defaultValue: '10000', + }, + deltaVersion: { + envVarName: 'SUPERVISOR_DELTA_VERSION', + varType: 'int', + defaultValue: '2', + }, + lockOverride: { + envVarName: 'SUPERVISOR_OVERRIDE_LOCK', + varType: 'bool', + defaultValue: 'false', + }, + persistentLogging: { + envVarName: 'SUPERVISOR_PERSISTENT_LOGGING', + varType: 'bool', + defaultValue: 'false', + rebootRequired: true, + }, + }; + + static validKeys = [ + 'SUPERVISOR_VPN_CONTROL', + 'OVERRRIDE_LOCK', + ..._.map(DeviceConfig.configKeys, 'envVarName'), + ]; + + public constructor({ db, config, logger }: DeviceConfigConstructOpts) { + this.db = db; + this.config = config; + this.logger = logger; + + this.actionExecutors = { + changeConfig: async step => { + try { + if (step.humanReadableTarget) { + this.logger.logConfigChange(step.humanReadableTarget); + } + if (!_.isObject(step.target)) { + throw new Error('Non-dictionary value passed to changeConfig'); + } + await this.config.set(step.target as Dictionary); + if (step.humanReadableTarget) { + this.logger.logConfigChange(step.humanReadableTarget, { + success: true, + }); + } + if (step.rebootRequired) { + this.rebootRequired = true; + } + } catch (err) { + if (step.humanReadableTarget) { + this.logger.logConfigChange(step.humanReadableTarget, { + err, + }); + } + throw err; + } + }, + setVPNEnabled: async (step, opts = {}) => { + const { initial = false } = opts; + if (!_.isString(step.target)) { + throw new Error('Non-string value passed to setVPNEnabled'); + } + const logValue = { SUPERVISOR_VPN_CONTROL: step.target }; + if (!initial) { + this.logger.logConfigChange(logValue); + } + try { + await this.setVPNEnabled(step.target); + if (!initial) { + this.logger.logConfigChange(logValue, { success: true }); + } + } catch (err) { + this.logger.logConfigChange(logValue, { err }); + throw err; + } + }, + setBootConfig: async step => { + const configBackend = await this.getConfigBackend(); + if (!_.isObject(step.target)) { + throw new Error( + 'Non-dictionary passed to DeviceConfig.setBootConfig', + ); + } + await this.setBootConfig(configBackend, step.target as Dictionary< + string + >); + }, + }; + } + + private async getConfigBackend() { + if (this.configBackend != null) { + return this.configBackend; + } + const dt = await this.config.get('deviceType'); + if (!_.isString(dt)) { + throw new Error('Could not detect device type'); + } + + this.configBackend = configUtils.getConfigBackend(dt) || null; + + return this.configBackend; + } + + public async setTarget( + target: Dictionary, + trx?: Transaction, + ): Promise { + const db = trx != null ? trx : this.db.models.bind(this.db); + + const formatted = await this.formatConfigKeys(target); + const confToUpdate = { + targetValues: JSON.stringify(formatted), + }; + await db('deviceConfig').update(confToUpdate); + } + + public async getTarget({ initial = false }: { initial?: boolean } = {}) { + const [unmanaged, [devConfig]] = await Promise.all([ + this.config.get('unmanaged'), + this.db.models('deviceConfig').select('targetValues'), + ]); + + let conf: Dictionary; + try { + conf = JSON.parse(devConfig.targetValues); + } catch (e) { + throw new Error(`Corrupted supervisor database! Error: ${e.message}`); + } + if (initial || conf.SUPERVISOR_VPN_CONTROL == null) { + conf.SUPERVISOR_VPN_CONTROL = 'true'; + } + if (unmanaged && conf.SUPERVISOR_LOCAL_MODE == null) { + conf.SUPERVISOR_LOCAL_MODE = 'true'; + } + + _.defaults( + conf, + _(DeviceConfig.configKeys) + .mapKeys('envVarName') + .mapValues('defaultValue') + .value(), + ); + + return conf; + } + + public async getCurrent() { + const conf = await this.config.getMany( + ['deviceType'].concat(_.keys(DeviceConfig.configKeys)), + ); + + const configBackend = await this.getConfigBackend(); + + const [vpnStatus, bootConfig] = await Promise.all([ + this.getVPNEnabled(), + this.getBootConfig(configBackend), + ]); + + const currentConf: Dictionary = { + // TODO: Fix this mess of half strings half boolean values everywhere + SUPERVISOR_VPN_CONTROL: vpnStatus != null ? vpnStatus.toString() : 'true', + }; + + for (const key in DeviceConfig.configKeys) { + const { envVarName } = DeviceConfig.configKeys[key]; + const confValue = conf[key]; + currentConf[envVarName] = confValue != null ? confValue.toString() : ''; + } + + return _.assign(currentConf, bootConfig); + } + + public async formatConfigKeys( + conf: Dictionary, + ): Promise> { + const backend = await this.getConfigBackend(); + return await configUtils.formatConfigKeys( + backend, + DeviceConfig.validKeys, + conf, + ); + } + + public getDefaults() { + return _.extend( + { + SUPERVISOR_VPN_CONTROL: 'true', + }, + _.mapValues( + _.mapKeys(DeviceConfig.configKeys, 'envVarName'), + 'defaultValues', + ), + ); + } + + private bootConfigChangeRequired( + configBackend: DeviceConfigBackend | null, + current: Dictionary, + target: Dictionary, + ): boolean { + const targetBootConfig = configUtils.envToBootConfig(configBackend, target); + const currentBootConfig = configUtils.envToBootConfig( + configBackend, + current, + ); + + if (!_.isEqual(currentBootConfig, targetBootConfig)) { + _.each(targetBootConfig, (value, key) => { + // Ignore null check because we can't get here if configBackend is null + if (!configBackend!.isSupportedConfig(key)) { + if (currentBootConfig[key] !== value) { + const err = `Attempt to change blacklisted config value ${key}`; + this.logger.logSystemMessage( + err, + { error: err }, + 'Apply boot config error', + ); + throw new Error(err); + } + } + }); + return true; + } + return false; + } + + public async getRequiredSteps( + currentState: DeviceApplicationState, + targetState: DeviceApplicationState, + ): Promise { + const current: Dictionary = _.get( + currentState, + ['local', 'config'], + {}, + ); + const target: Dictionary = _.get( + targetState, + ['local', 'config'], + {}, + ); + + const steps: ConfigStep[] = []; + + const unmanaged = await this.config.get('unmanaged'); + const backend = await this.getConfigBackend(); + + const configChanges: Dictionary = {}; + const humanReadableConfigChanges: Dictionary = {}; + let reboot = false; + + // If the legacy lock override is used, place it as the new variable + if (checkTruthy(target['OVERRIDE_LOCK'])) { + target['SUPERVISOR_OVERRIDE_LOCK'] = target['OVERRIDE_LOCK']; + } + + _.each( + DeviceConfig.configKeys, + ({ envVarName, varType, rebootRequired }, key) => { + // Test if the key is different + if ( + !DeviceConfig.configTest( + varType, + current[envVarName], + target[envVarName], + ) + ) { + // Save the change if it is + configChanges[key] = target[envVarName]; + humanReadableConfigChanges[envVarName] = target[envVarName]; + reboot = rebootRequired || reboot; + } + }, + ); + + if (!_.isEmpty(configChanges)) { + steps.push({ + action: 'changeConfig', + target: configChanges, + humanReadableTarget: humanReadableConfigChanges, + rebootRequired: reboot, + }); + } + + // Check for special case actions for the VPN + if ( + !checkTruthy(unmanaged || false) && + !_.isEmpty(target['SUPERVISOR_VPN_CONTROL']) && + DeviceConfig.checkBoolChanged(current, target, 'SUPERVISOR_VPN_CONTROL') + ) { + steps.push({ + action: 'setVPNEnabled', + target: target['SUPERVISOR_VPN_CONTROL'], + }); + } + + // Do we need to change the boot config? + if (this.bootConfigChangeRequired(backend, current, target)) { + steps.push({ + action: 'setBootConfig', + target, + }); + } + + if (_.isEmpty(steps) && this.rebootRequired) { + steps.push({ + action: 'reboot', + }); + } + + return steps; + } + + public executeStepAction(step: ConfigStep, opts: DeviceActionExecutorOpts) { + if (step.action !== 'reboot') { + return this.actionExecutors[step.action](step, opts); + } + } + + public isValidAction(action: string): boolean { + return _.includes(_.keys(this.actionExecutors), action); + } + + private async getBootConfig( + backend: DeviceConfigBackend | null, + ): Promise { + if (backend == null) { + return {}; + } + const conf = await backend.getBootConfig(); + return configUtils.bootConfigToEnv(backend, conf); + } + + private async setBootConfig( + backend: DeviceConfigBackend | null, + target: Dictionary, + ) { + if (backend == null) { + return false; + } + + const conf = configUtils.envToBootConfig(backend, target); + this.logger.logSystemMessage( + `Applying boot config: ${JSON.stringify(conf)}`, + {}, + 'Apply boot config in progress', + ); + + try { + await backend.setBootConfig(conf); + this.logger.logSystemMessage( + `Applied boot config: ${JSON.stringify(conf)}`, + {}, + 'Apply boot config success', + ); + this.rebootRequired = true; + return true; + } catch (err) { + this.logger.logSystemMessage( + `Error setting boot config: ${err}`, + { error: err }, + 'Apply boot config error', + ); + throw err; + } + } + + private async getVPNEnabled(): Promise { + try { + const activeState = await systemd.serviceActiveState(vpnServiceName); + return !_.includes(['inactive', 'deactivating'], activeState); + } catch (e) { + if (UnitNotLoadedError(e)) { + return false; + } + throw e; + } + } + + private async setVPNEnabled(value?: string | boolean) { + const v = checkTruthy(value || true); + const enable = v != null ? v : true; + + if (enable) { + await systemd.startService(vpnServiceName); + } else { + await systemd.stopService(vpnServiceName); + } + } + + private static configTest(method: string, a: string, b: string): boolean { + switch (method) { + case 'bool': + return checkTruthy(a) === checkTruthy(b); + case 'int': + return checkInt(a) === checkInt(b); + default: + throw new Error('Incorrect datatype passed to DeviceConfig.configTest'); + } + } + + private static checkBoolChanged( + current: Dictionary, + target: Dictionary, + key: string, + ): boolean { + return checkTruthy(current[key]) !== checkTruthy(target[key]); + } +} + +export default DeviceConfig; diff --git a/src/device-state.coffee b/src/device-state.coffee index 4de29878..9172530a 100644 --- a/src/device-state.coffee +++ b/src/device-state.coffee @@ -19,7 +19,7 @@ updateLock = require './lib/update-lock' { singleToMulticontainerApp } = require './lib/migration' { ENOENT, EISDIR, NotFoundError, UpdatesLockedError } = require './lib/errors' -DeviceConfig = require './device-config' +{ DeviceConfig } = require './device-config' ApplicationManager = require './application-manager' validateLocalState = (state) -> @@ -534,7 +534,7 @@ module.exports = class DeviceState extends EventEmitter executeStepAction: (step, { force, initial, skipLock }) => Promise.try => - if _.includes(@deviceConfig.validActions, step.action) + if @deviceConfig.isValidAction(step.action) @deviceConfig.executeStepAction(step, { initial }) else if _.includes(@applications.validActions, step.action) @applications.executeStepAction(step, { force, skipLock }) diff --git a/src/host-config.coffee b/src/host-config.coffee deleted file mode 100644 index 7c2a3f01..00000000 --- a/src/host-config.coffee +++ /dev/null @@ -1,127 +0,0 @@ -Promise = require 'bluebird' -_ = require 'lodash' -systemd = require './lib/systemd' -path = require 'path' -constants = require './lib/constants' -fs = Promise.promisifyAll(require('fs')) -{ writeFileAtomic } = require './lib/fs-utils' -mkdirp = Promise.promisify(require('mkdirp')) - -ENOENT = (err) -> err.code is 'ENOENT' - -redsocksHeader = ''' - base { - log_debug = off; - log_info = on; - log = stderr; - daemon = off; - redirector = iptables; - } - - redsocks { - local_ip = 127.0.0.1; - local_port = 12345; - - ''' - -redsocksFooter = '}\n' - -proxyFields = [ 'type', 'ip', 'port', 'login', 'password' ] - -proxyBasePath = path.join(constants.rootMountPoint, constants.bootMountPoint, 'system-proxy') -redsocksConfPath = path.join(proxyBasePath, 'redsocks.conf') -noProxyPath = path.join(proxyBasePath, 'no_proxy') - -readProxy = -> - fs.readFileAsync(redsocksConfPath) - .then (redsocksConf) -> - lines = new String(redsocksConf).split('\n') - conf = {} - for line in lines - for proxyField in proxyFields - if proxyField in [ 'login', 'password' ] - m = line.match(new RegExp(proxyField + '\\s*=\\s*\"(.*)\"\\s*;')) - else - m = line.match(new RegExp(proxyField + '\\s*=\\s*([^;\\s]*)\\s*;')) - if m? - conf[proxyField] = m[1] - return conf - .catch ENOENT, -> - return null - .then (conf) -> - if !conf? - return null - else - fs.readFileAsync(noProxyPath) - .then (noProxy) -> - conf.noProxy = new String(noProxy).split('\n') - return conf - .catch ENOENT, -> - return conf - -generateRedsocksConfEntries = (conf) -> - val = '' - for field in proxyFields - if conf[field]? - v = conf[field] - if field in [ 'login', 'password' ] - v = "\"#{v}\"" - val += "\t#{field} = #{v};\n" - return val - -setProxy = (conf) -> - Promise.try -> - if _.isEmpty(conf) - fs.unlinkAsync(redsocksConfPath) - .catch(ENOENT, _.noop) - .then -> - fs.unlinkAsync(noProxyPath) - .catch(ENOENT, _.noop) - else - mkdirp(proxyBasePath) - .then -> - if _.isArray(conf.noProxy) - writeFileAtomic(noProxyPath, conf.noProxy.join('\n')) - .then -> - redsocksConf = '' - redsocksConf += redsocksHeader - redsocksConf += generateRedsocksConfEntries(conf) - redsocksConf += redsocksFooter - writeFileAtomic(redsocksConfPath, redsocksConf) - .then -> - systemd.restartService('resin-proxy-config') - .then -> - systemd.restartService('redsocks') - -hostnamePath = path.join(constants.rootMountPoint, '/etc/hostname') -readHostname = -> - fs.readFileAsync(hostnamePath) - .then (hostnameData) -> - return _.trim(new String(hostnameData)) - -setHostname = (val, configModel) -> - configModel.set(hostname: val) - .then -> - systemd.restartService('resin-hostname') - - -exports.get = -> - Promise.join( - readProxy() - readHostname() - (proxy, hostname) -> - return { - network: { - proxy - hostname - } - } - ) - -exports.patch = (conf, configModel) -> - Promise.try -> - if !_.isUndefined(conf?.network?.proxy) - setProxy(conf.network.proxy) - .then -> - if !_.isUndefined(conf?.network?.hostname) - setHostname(conf.network.hostname, configModel) diff --git a/src/host-config.ts b/src/host-config.ts new file mode 100644 index 00000000..9bed8294 --- /dev/null +++ b/src/host-config.ts @@ -0,0 +1,182 @@ +import * as Bluebird from 'bluebird'; +import { stripIndent } from 'common-tags'; +import * as _ from 'lodash'; +import * as mkdirCb from 'mkdirp'; +import { fs } from 'mz'; +import * as path from 'path'; + +import Config = require('./config'); +import * as constants from './lib/constants'; +import { ENOENT } from './lib/errors'; +import { writeFileAtomic } from './lib/fs-utils'; +import * as systemd from './lib/systemd'; + +const mkdirp = Bluebird.promisify(mkdirCb) as ( + path: string, + opts?: any, +) => Bluebird; + +const redsocksHeader = stripIndent` + base { + log_debug = off; + log_info = on; + log = stderr; + daemon = off; + redirector = iptables; + } + + redsocks { + local_ip = 127.0.0.1; + local_port = 12345; +`; + +const redsocksFooter = '}\n'; + +const proxyFields = ['type', 'ip', 'port', 'login', 'password']; + +const proxyBasePath = path.join( + constants.rootMountPoint, + constants.bootMountPoint, + 'system-proxy', +); +const redsocksConfPath = path.join(proxyBasePath, 'redsocks.conf'); +const noProxyPath = path.join(proxyBasePath, 'no_proxy'); + +interface ProxyConfig { + [key: string]: string | string[]; +} + +interface HostConfig { + network: { + proxy?: ProxyConfig; + hostname?: string; + }; +} + +const isAuthField = (field: string): boolean => + _.includes(['login', 'password'], field); + +const memoizedAuthRegex = _.memoize( + (proxyField: string) => new RegExp(proxyField + '\\s*=\\s*"(.*)"\\s*;'), +); + +const memoizedRegex = _.memoize( + proxyField => new RegExp(proxyField + '\\s*=\\s*([^;\\s]*)\\s*;'), +); + +async function readProxy(): Promise { + const conf: ProxyConfig = {}; + let redsocksConf: string; + try { + redsocksConf = await fs.readFile(redsocksConfPath, 'utf-8'); + } catch (e) { + if (!ENOENT(e)) { + throw e; + } + return; + } + const lines = redsocksConf.split('\n'); + + for (const line of lines) { + for (const proxyField of proxyFields) { + let match: string[] | null = null; + if (isAuthField(proxyField)) { + match = line.match(memoizedAuthRegex(proxyField)); + } else { + match = line.match(memoizedRegex(proxyField)); + } + + if (match != null) { + conf[proxyField] = match[1]; + } + } + } + + try { + const noProxy = await fs.readFile(noProxyPath, 'utf-8'); + conf.noProxy = noProxy.split('\n'); + } catch (e) { + if (!ENOENT(e)) { + throw e; + } + } + return conf; +} + +function generateRedsocksConfEntries(conf: ProxyConfig): string { + let val = ''; + for (const field of proxyFields) { + let v = conf[field]; + if (v != null) { + if (isAuthField(field)) { + v = `"${v}"`; + } + val += `\t${field} = ${v};\n`; + } + } + return val; +} + +async function setProxy(maybeConf: ProxyConfig | null): Promise { + if (_.isEmpty(maybeConf)) { + try { + await Promise.all([fs.unlink(redsocksConfPath), fs.unlink(noProxyPath)]); + } catch (e) { + if (!ENOENT(e)) { + throw e; + } + } + } else { + // We know that maybeConf is not null due to the _.isEmpty check above, + // but the compiler doesn't + const conf = maybeConf as ProxyConfig; + await mkdirp(proxyBasePath); + if (_.isArray(conf.noProxy)) { + await writeFileAtomic(noProxyPath, conf.noProxy.join('\n')); + } + const redsocksConf = `${redsocksHeader}${generateRedsocksConfEntries( + conf, + )}${redsocksFooter}`; + await writeFileAtomic(redsocksConfPath, redsocksConf); + } + + await systemd.restartService('resin-proxy-config'); + await systemd.restartService('redsocks'); +} + +const hostnamePath = path.join(constants.rootMountPoint, '/etc/hostname'); +async function readHostname() { + const hostnameData = await fs.readFile(hostnamePath, 'utf-8'); + return _.trim(hostnameData); +} + +async function setHostname(val: string, configModel: Config) { + await configModel.set({ hostname: val }); + await systemd.restartService('resin-hostname'); +} + +// Don't use async/await here to maintain the bluebird +// promises being returned +export function get(): Bluebird { + return Bluebird.join(readProxy(), readHostname(), (proxy, hostname) => { + return { + network: { + proxy, + hostname, + }, + }; + }); +} + +export function patch(conf: HostConfig, configModel: Config): Bluebird { + const promises = []; + if (conf != null && conf.network != null) { + if (conf.network.proxy != null) { + promises.push(setProxy(conf.network.proxy)); + } + if (conf.network.hostname != null) { + promises.push(setHostname(conf.network.hostname, configModel)); + } + } + return Bluebird.all(promises).return(); +} diff --git a/src/local-mode.ts b/src/local-mode.ts index fcd45b60..d12049c4 100644 --- a/src/local-mode.ts +++ b/src/local-mode.ts @@ -3,7 +3,7 @@ import * as Docker from 'dockerode'; import * as _ from 'lodash'; import Config = require('./config'); -import Database = require('./db'); +import Database from './db'; import { checkTruthy } from './lib/validation'; import { Logger } from './logger'; diff --git a/src/logger.ts b/src/logger.ts index 0112c9fa..c89ef35d 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -315,3 +315,5 @@ export class Logger { return null; } } + +export default Logger; diff --git a/src/supervisor.coffee b/src/supervisor.coffee index aa5b0319..61763a27 100644 --- a/src/supervisor.coffee +++ b/src/supervisor.coffee @@ -1,7 +1,7 @@ EventEmitter = require 'events' { EventTracker } = require './event-tracker' -DB = require './db' +{ DB } = require './db' Config = require './config' APIBinder = require './api-binder' DeviceState = require './device-state' diff --git a/src/types/state.ts b/src/types/state.ts index 621053d8..7f68d15b 100644 --- a/src/types/state.ts +++ b/src/types/state.ts @@ -1,11 +1,14 @@ export interface DeviceApplicationState { - local: { - [appId: string]: { - services: { - [serviceId: string]: { - status: string; - releaseId: number; - download_progress: number | null; + local?: { + config?: Dictionary; + apps?: { + [appId: string]: { + services: { + [serviceId: string]: { + status: string; + releaseId: number; + download_progress: number | null; + }; }; }; }; diff --git a/test/02-db.spec.coffee b/test/02-db.spec.coffee index 7405e20c..1828a532 100644 --- a/test/02-db.spec.coffee +++ b/test/02-db.spec.coffee @@ -4,7 +4,7 @@ m = require 'mochainon' { expect } = m.chai fs = Promise.promisifyAll(require('fs')) Knex = require('knex') -DB = require('../src/db') +{ DB } = require('../src/db') createOldDatabase = (path) -> knex = new Knex( diff --git a/test/03-config.spec.coffee b/test/03-config.spec.coffee index 8d3b8a10..f4590053 100644 --- a/test/03-config.spec.coffee +++ b/test/03-config.spec.coffee @@ -5,7 +5,7 @@ m = require 'mochainon' fs = Promise.promisifyAll(require('fs')) m.chai.use(require('chai-events')) -DB = require('../src/db') +{ DB } = require('../src/db') Config = require('../src/config') constants = require('../src/lib/constants') diff --git a/test/05-device-state.spec.coffee b/test/05-device-state.spec.coffee index 0eba0197..5d492983 100644 --- a/test/05-device-state.spec.coffee +++ b/test/05-device-state.spec.coffee @@ -7,7 +7,7 @@ m.chai.use(require('chai-events')) prepare = require './lib/prepare' DeviceState = require '../src/device-state' -DB = require('../src/db') +{ DB } = require('../src/db') Config = require('../src/config') { RPiConfigBackend } = require('../src/config/backend') diff --git a/test/11-api-binder.spec.coffee b/test/11-api-binder.spec.coffee index 561689c3..312dd82e 100644 --- a/test/11-api-binder.spec.coffee +++ b/test/11-api-binder.spec.coffee @@ -7,7 +7,7 @@ m = require 'mochainon' { expect } = m.chai { stub, spy } = m.sinon -DB = require('../src/db') +{ DB } = require('../src/db') Config = require('../src/config') DeviceState = require('../src/device-state') APIBinder = require('../src/api-binder') diff --git a/test/13-device-config.spec.coffee b/test/13-device-config.spec.coffee index 0a3083f4..55f0b1dd 100644 --- a/test/13-device-config.spec.coffee +++ b/test/13-device-config.spec.coffee @@ -8,7 +8,7 @@ m = require 'mochainon' prepare = require './lib/prepare' fsUtils = require '../src/lib/fs-utils' -DeviceConfig = require '../src/device-config' +{ DeviceConfig } = require '../src/device-config' { ExtlinuxConfigBackend, RPiConfigBackend } = require '../src/config/backend' extlinuxBackend = new ExtlinuxConfigBackend() diff --git a/test/14-application-manager.spec.coffee b/test/14-application-manager.spec.coffee index 38ed6652..1985f29d 100644 --- a/test/14-application-manager.spec.coffee +++ b/test/14-application-manager.spec.coffee @@ -8,7 +8,7 @@ m.chai.use(require('chai-events')) prepare = require './lib/prepare' DeviceState = require '../src/device-state' -DB = require('../src/db') +{ DB } = require('../src/db') Config = require('../src/config') { Service } = require '../src/compose/service'