diff --git a/package.json b/package.json index 33d7e4c2..4df95b6d 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,9 @@ }, "dependencies": { "@types/lodash": "^4.14.108", + "@types/mz": "0.0.32", "mkfifo": "^0.1.5", + "mz": "^2.7.0", "sqlite3": "^3.1.0" }, "engines": { diff --git a/src/device-config.coffee b/src/device-config.coffee index e228ab2f..6b635c7d 100644 --- a/src/device-config.coffee +++ b/src/device-config.coffee @@ -1,20 +1,12 @@ Promise = require 'bluebird' _ = require 'lodash' -childProcess = Promise.promisifyAll(require('child_process')) fs = Promise.promisifyAll(require('fs')) -constants = require './lib/constants' systemd = require './lib/systemd' -fsUtils = require './lib/fs-utils' { checkTruthy, checkInt } = require './lib/validation' { UnitNotLoadedError } = require './lib/errors' +configUtils = require './lib/config-utils' -hostConfigConfigVarPrefix = 'RESIN_HOST_' -bootConfigEnvVarPrefix = hostConfigConfigVarPrefix + 'CONFIG_' -bootBlockDevice = '/dev/mmcblk0p1' -bootMountPoint = constants.rootMountPoint + constants.bootMountPoint -bootConfigPath = bootMountPoint + '/config.txt' -configRegex = new RegExp('(' + _.escapeRegExp(bootConfigEnvVarPrefix) + ')(.+)') forbiddenConfigKeys = [ 'disable_commandline_tags' 'cmdline' @@ -32,13 +24,9 @@ forbiddenConfigKeys = [ 'boot_delay_ms' 'avoid_safe_mode' ] -arrayConfigKeys = [ 'dtparam', 'dtoverlay', 'device_tree_param', 'device_tree_overlay' ] vpnServiceName = 'openvpn-resin' -isRPiDeviceType = (deviceType) -> - _.startsWith(deviceType, 'raspberry') or deviceType == 'fincm3' - module.exports = class DeviceConfig constructor: ({ @db, @config, @logger }) -> @rebootRequired = false @@ -89,16 +77,15 @@ module.exports = class DeviceConfig } db('deviceConfig').update(confToUpdate) - filterConfigKeys: (conf) => - _.pickBy conf, (v, k) => - _.includes(@validKeys, k) or _.startsWith(k, bootConfigEnvVarPrefix) - getTarget: ({ initial = false } = {}) => @db.models('deviceConfig').select('targetValues') - .then ([ devConfig ]) -> - return JSON.parse(devConfig.targetValues) - .then (conf) => - conf = @filterConfigKeys(conf) + .then ([ devConfig ]) => + return Promise.all [ + JSON.parse(devConfig.targetValues) + @config.get('deviceType') + ] + .then ([ conf, deviceType ]) => + conf = configUtils.filterConfigKeys(deviceType, @validKeys, conf) if initial or !conf.RESIN_SUPERVISOR_VPN_CONTROL? conf.RESIN_SUPERVISOR_VPN_CONTROL = 'true' for own k, { envVarName, defaultValue } of @configKeys @@ -127,8 +114,8 @@ module.exports = class DeviceConfig }, _.mapValues(_.mapKeys(@configKeys, 'envVarName'), 'defaultValue')) bootConfigChangeRequired: (deviceType, current, target) => - targetBootConfig = @envToBootConfig(target) - currentBootConfig = @envToBootConfig(current) + targetBootConfig = configUtils.envToBootConfig(deviceType, target) + currentBootConfig = configUtils.envToBootConfig(deviceType, current) if !_.isEqual(currentBootConfig, targetBootConfig) for key in forbiddenConfigKeys if currentBootConfig[key] != targetBootConfig[key] @@ -190,75 +177,27 @@ module.exports = class DeviceConfig executeStepAction: (step, opts) => @actionExecutors[step.action](step, opts) - envToBootConfig: (env) -> - # We ensure env doesn't have garbage - parsedEnv = _.pickBy env, (val, key) -> - return _.startsWith(key, bootConfigEnvVarPrefix) - parsedEnv = _.mapKeys parsedEnv, (val, key) -> - key.replace(configRegex, '$2') - parsedEnv = _.mapValues parsedEnv, (val, key) -> - if _.includes(arrayConfigKeys, key) - if !_.startsWith(val, '"') - return [ val ] - else - return JSON.parse("[#{val}]") - else - return val - return parsedEnv - - bootConfigToEnv: (config) -> - confWithEnvKeys = _.mapKeys config, (val, key) -> - return bootConfigEnvVarPrefix + key - return _.mapValues confWithEnvKeys, (val, key) -> - if _.isArray(val) - return JSON.stringify(val).replace(/^\[(.*)\]$/, '$1') - else - return val - - readBootConfig: -> - fs.readFileAsync(bootConfigPath, 'utf8') + readBootConfig: (deviceType) -> + fs.readFileAsync(configUtils.getBootConfigPath(deviceType), 'utf8') getBootConfig: (deviceType) => Promise.try => - if !isRPiDeviceType(deviceType) + if !configUtils.isConfigDeviceType(deviceType) return {} - @readBootConfig() - .then (configTxt) => - conf = {} - configStatements = configTxt.split(/\r?\n/) - for configStr in configStatements - keyValue = /^([^#=]+)=(.+)/.exec(configStr) - if keyValue? - if !_.includes(arrayConfigKeys, keyValue[1]) - conf[keyValue[1]] = keyValue[2] - else - conf[keyValue[1]] ?= [] - conf[keyValue[1]].push(keyValue[2]) - else - keyValue = /^(initramfs) (.+)/.exec(configStr) - if keyValue? - conf[keyValue[1]] = keyValue[2] - return @bootConfigToEnv(conf) + @readBootConfig(deviceType) + .then (configTxt) -> + + config = configUtils.parseBootConfig(deviceType, configTxt) + return configUtils.bootConfigToEnv(deviceType, config) setBootConfig: (deviceType, target) => Promise.try => - conf = @envToBootConfig(target) - if !isRPiDeviceType(deviceType) + if !configUtils.isConfigDeviceType(deviceType) return false + conf = configUtils.envToBootConfig(deviceType, target) @logger.logSystemMessage("Applying boot config: #{JSON.stringify(conf)}", {}, 'Apply boot config in progress') - configStatements = [] - for own key, val of conf - if key is 'initramfs' - configStatements.push("#{key} #{val}") - else if _.isArray(val) - configStatements = configStatements.concat(_.map(val, (entry) -> "#{key}=#{entry}")) - else - configStatements.push("#{key}=#{val}") - # Here's the dangerous part: - childProcess.execAsync("mount -t vfat -o remount,rw #{bootBlockDevice} #{bootMountPoint}") - .then -> - fsUtils.writeFileAtomic(bootConfigPath, configStatements.join('\n') + '\n') + configUtils.setBootConfig(deviceType, conf) .then => @logger.logSystemMessage("Applied boot config: #{JSON.stringify(conf)}", {}, 'Apply boot config success') @rebootRequired = true diff --git a/src/lib/config-utils.ts b/src/lib/config-utils.ts new file mode 100644 index 00000000..e58b5690 --- /dev/null +++ b/src/lib/config-utils.ts @@ -0,0 +1,198 @@ +import * as Promise from 'bluebird'; +import * as childProcessSync from 'child_process'; +import * as _ from 'lodash'; + +import * as constants from './constants'; +import * as fsUtils from './fs-utils'; +import { EnvVarObject } from './types'; + +const childProcess: any = Promise.promisifyAll(childProcessSync); + +export interface ConfigOptions { + [key: string]: string | string[]; +} + +export const rpiArrayConfigKeys = [ + 'dtparam', + 'dtoverlay', + 'device_tree_param', + 'device_tree_overlay', +]; + +export const hostConfigConfigVarPrefix = 'RESIN_HOST_'; +export const rPiBootConfigPrefix = hostConfigConfigVarPrefix + 'CONFIG_'; +export const rPiConfigRegex = new RegExp('(' + _.escapeRegExp(rPiBootConfigPrefix) + ')(.+)'); + +export const bootMountPoint = constants.rootMountPoint + constants.bootMountPoint; + +export function isRPiDeviceType(deviceType: string): boolean { + return _.startsWith(deviceType, 'raspberry') || deviceType === 'fincm3'; +} + +export function isConfigDeviceType(deviceType: string): boolean { + return isRPiDeviceType(deviceType); +} + +export function bootConfigVarPrefix(deviceType: string): string { + if (isRPiDeviceType(deviceType)) { + return rPiBootConfigPrefix; + } else { + throw new Error(`No boot config var prefix for device type: ${deviceType}`); + } +} + +export function bootConfigVarRegex(deviceType: string): RegExp { + if (isRPiDeviceType(deviceType)) { + return rPiConfigRegex; + } else { + throw new Error(`No boot config var regex for device type: ${deviceType}`); + } +} + +export function envToBootConfig(deviceType: string, env: EnvVarObject): ConfigOptions { + if (!isConfigDeviceType(deviceType)) { + return { }; + } + const prefix = bootConfigVarPrefix(deviceType); + const regex = bootConfigVarRegex(deviceType); + + let parsedEnv = _.pickBy(env, (_val: string, key: string) => { + return _.startsWith(key, prefix); + }); + parsedEnv = _.mapKeys(parsedEnv, (_val, key) => { + return key.replace(regex, '$2'); + }); + parsedEnv = _.mapValues(parsedEnv, (val, key) => { + if (isRPiDeviceType(deviceType)) { + if (_.includes(rpiArrayConfigKeys, key)) { + if (!_.startsWith(val, '"')) { + return [ val ]; + } else { + return JSON.parse(`[${val}]`); + } + } + } + return val; + }); + + return parsedEnv as ConfigOptions; +} + +export function bootConfigToEnv(deviceType: string, config: ConfigOptions): EnvVarObject { + const prefix = bootConfigVarPrefix(deviceType); + const confWithEnvKeys = _.mapKeys(config, (_val, key) => { + return prefix + key; + }); + return _.mapValues(confWithEnvKeys, (val) => { + if (_.isArray(val)) { + return JSON.stringify(val).replace(/^\[(.*)\]$/, '$1'); + } + return val; + }); +} + +export function filterConfigKeys( + deviceType: string, + allowedKeys: string[], + conf: { [key: string]: any }, +): { [key: string]: any } { + + let isConfigType: boolean = false; + let prefix: string; + if (isConfigDeviceType(deviceType)) { + prefix = bootConfigVarPrefix(deviceType); + isConfigType = true; + } + return _.pickBy(conf, (_v, k) => { + return _.includes(allowedKeys, k) || (isConfigType && _.startsWith(k, prefix)); + }); +} + +export function getBootConfigPath(deviceType: string): string { + if (isRPiDeviceType(deviceType)) { + return getRpiBootConfig(); + } else { + throw new Error(`No boot config exists for device type: ${deviceType}`); + } +} + +function getRpiBootConfig(): string { + return bootMountPoint + '/config.txt'; +} + +export function parseBootConfig(deviceType: string, conf: string): ConfigOptions { + if (isRPiDeviceType(deviceType)) { + return parseRpiBootConfig(conf); + } else { + throw new Error(`Cannot parse boot config for device type: ${deviceType}`); + } +} + +export function parseRpiBootConfig(confStr: string): ConfigOptions { + const conf: ConfigOptions = { }; + const configStatements = confStr.split(/\r?\n/); + + for (const configStr of configStatements) { + // Don't show warnings for comments and empty lines + const trimmed = _.trimStart(configStr); + if (_.startsWith(trimmed, '#') || trimmed === '') { + continue; + } + let keyValue = /^([^#=]+)=(.+)/.exec(configStr); + if (keyValue != null) { + if (!_.includes(rpiArrayConfigKeys, keyValue[1])) { + conf[keyValue[1]] = keyValue[2]; + } else { + const key = keyValue[1]; + if (conf[key] == null) { + conf[key] = []; + } + (conf[key] as string[]).push(keyValue[2]); + } + } else { + keyValue = /^(initramfs) (.+)/.exec(configStr); + if (keyValue != null) { + conf[keyValue[1]] = keyValue[2]; + } else { + console.log(`Warning - Could not parse config.txt entry: ${configStr}. Ignoring.`); + } + } + } + + return conf; +} + +export function setBootConfig(deviceType: string, target: ConfigOptions): Promise { + if (isRPiDeviceType(deviceType)) { + return setRpiBootConfig(target); + } else { + throw new Error(`Could not set boot config for non-boot config device: ${deviceType}`); + } +} + +function setRpiBootConfig(target: ConfigOptions): Promise { + let confStatements: string[] = []; + + _.each(target, (value, key) => { + + if (key === 'initramfs') { + confStatements.push(`${key} ${value}`); + } else if(_.isArray(value)) { + confStatements = confStatements.concat(_.map(value, (entry) => `${key}=${entry}`)); + } else { + confStatements.push(`${key}=${value}`); + } + }); + + return remountAndWriteAtomic(getRpiBootConfig(), confStatements.join('\n') + '\n'); +} + +function remountAndWriteAtomic(file: string, data: string): Promise { + // TODO: Find out why the below Promise.resolve() is required + // Here's the dangerous part: + return Promise.resolve(childProcess.execAsync(`mount -t vfat -o remount,rw ${constants.bootBlockDevice} ${bootMountPoint}`)) + .then(() => { + return fsUtils.writeFileAtomic(file, data); + }) + .return(); +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 12d34f2c..2c5ca2a8 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -39,6 +39,7 @@ const constants = { defaultVolumeLabels: { 'io.resin.supervised': 'true', }, + bootBlockDevice: '/dev/mmcblk0p1', }; if (process.env.DOCKER_HOST == null) { diff --git a/test/13-device-config.spec.coffee b/test/13-device-config.spec.coffee index 7584b5d3..3bd3ff10 100644 --- a/test/13-device-config.spec.coffee +++ b/test/13-device-config.spec.coffee @@ -54,19 +54,6 @@ describe 'DeviceConfig', -> RESIN_HOST_CONFIG_gpu_mem: '16' }) - it 'correctly transforms environments to boot config objects', -> - bootConfig = @deviceConfig.envToBootConfig({ - RESIN_HOST_CONFIG_initramfs: 'initramf.gz 0x00800000' - RESIN_HOST_CONFIG_dtparam: '"i2c=on","audio=on"' - RESIN_HOST_CONFIG_dtoverlay: '"ads7846","lirc-rpi,gpio_out_pin=17,gpio_in_pin=13"' - RESIN_HOST_CONFIG_foobar: 'baz' - }) - expect(bootConfig).to.deep.equal({ - initramfs: 'initramf.gz 0x00800000' - dtparam: [ 'i2c=on', 'audio=on' ] - dtoverlay: [ 'ads7846', 'lirc-rpi,gpio_out_pin=17,gpio_in_pin=13' ] - foobar: 'baz' - }) # Test that the format for special values like initramfs and array variables is preserved it 'does not allow setting forbidden keys', -> current = { diff --git a/test/16-config-utils.spec.coffee b/test/16-config-utils.spec.coffee new file mode 100644 index 00000000..045a18db --- /dev/null +++ b/test/16-config-utils.spec.coffee @@ -0,0 +1,24 @@ +m = require 'mochainon' +{ expect } = m.chai + +configUtils = require '../src/lib/config-utils' + +describe 'Config Utilities', -> + + describe 'Boot config utilities', -> + + describe 'Env <-> Config', -> + + it 'correctly transforms environments to boot config objects', -> + bootConfig = configUtils.envToBootConfig('raspberry-pi', { + RESIN_HOST_CONFIG_initramfs: 'initramf.gz 0x00800000' + RESIN_HOST_CONFIG_dtparam: '"i2c=on","audio=on"' + RESIN_HOST_CONFIG_dtoverlay: '"ads7846","lirc-rpi,gpio_out_pin=17,gpio_in_pin=13"' + RESIN_HOST_CONFIG_foobar: 'baz' + }) + expect(bootConfig).to.deep.equal({ + initramfs: 'initramf.gz 0x00800000' + dtparam: [ 'i2c=on', 'audio=on' ] + dtoverlay: [ 'ads7846', 'lirc-rpi,gpio_out_pin=17,gpio_in_pin=13' ] + foobar: 'baz' + })