import _ from 'lodash'; import { inspect } from 'util'; import { promises as fs } from 'fs'; import * as config from './config'; import * as db from './db'; import * as logger from './logger'; import * as dbus from './lib/dbus'; import type { EnvVarObject } from './types'; import { UnitNotLoadedError } from './lib/errors'; import { checkInt, checkTruthy } from './lib/validation'; import log from './lib/supervisor-console'; import * as configUtils from './config/utils'; import type { SchemaTypeKey } from './config/schema-type'; import { matchesAnyBootConfig } from './config/backends'; import type { ConfigBackend } from './config/backends/backend'; import { Odmdata } from './config/backends/odmdata'; import * as fsUtils from './lib/fs-utils'; import { pathOnRoot } from './lib/host-utils'; const vpnServiceName = 'openvpn'; // This indicates the file on the host /tmp directory that // marks the need for a reboot. Since reboot is only triggered for now // by some config changes, we leave this here for now. There is planned // functionality to allow image installs to require reboots, at that moment // this constant can be moved somewhere else const REBOOT_BREADCRUMB = pathOnRoot( '/tmp/balena-supervisor/reboot-after-apply', ); interface ConfigOption { envVarName: string; varType: string; defaultValue: string; rebootRequired?: boolean; } // FIXME: Bring this and the deviceState and // applicationState steps together export 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' | 'noop'; humanReadableTarget?: Dictionary; target?: string | Dictionary; } interface DeviceActionExecutorOpts { initial?: boolean; } type DeviceActionExecutorFn = ( step: ConfigStep, opts?: DeviceActionExecutorOpts, ) => Promise; interface DeviceActionExecutors { changeConfig: DeviceActionExecutorFn; setVPNEnabled: DeviceActionExecutorFn; setBootConfig: DeviceActionExecutorFn; setRebootBreadcrumb: DeviceActionExecutorFn; } const actionExecutors: DeviceActionExecutors = { changeConfig: async (step) => { try { if (step.humanReadableTarget) { logger.logConfigChange(step.humanReadableTarget); } if (!_.isObject(step.target)) { throw new Error('Non-dictionary value passed to changeConfig'); } // TODO: Change the typing of step so that the types automatically // work out and we don't need this cast to any await config.set(step.target as { [key in SchemaTypeKey]: any }); if (step.humanReadableTarget) { logger.logConfigChange(step.humanReadableTarget, { success: true, }); } } catch (err: any) { if (step.humanReadableTarget) { logger.logConfigChange(step.humanReadableTarget, { err, }); } throw err; } }, setVPNEnabled: async (step, opts = {}) => { const { initial = false } = opts; if (typeof step.target !== 'string') { throw new Error('Non-string value passed to setVPNEnabled'); } const logValue = { SUPERVISOR_VPN_CONTROL: step.target }; if (!initial) { logger.logConfigChange(logValue); } try { await setVPNEnabled(step.target); if (!initial) { logger.logConfigChange(logValue, { success: true }); } } catch (err: any) { logger.logConfigChange(logValue, { err }); throw err; } }, setBootConfig: async (step) => { if (!_.isObject(step.target)) { throw new Error('Non-dictionary passed to DeviceConfig.setBootConfig'); } const backends = await getConfigBackends(); for (const backend of backends) { await setBootConfig(backend, step.target as Dictionary); } }, setRebootBreadcrumb: async () => { // Just create the file. The last step in the target state calculation will check // the file and create a reboot step await fsUtils.touch(REBOOT_BREADCRUMB); }, }; const configBackends: ConfigBackend[] = []; const configKeys: Dictionary = { appUpdatePollInterval: { envVarName: 'SUPERVISOR_POLL_INTERVAL', varType: 'int', defaultValue: '900000', }, instantUpdates: { envVarName: 'SUPERVISOR_INSTANT_UPDATE_TRIGGER', varType: 'bool', defaultValue: 'true', }, 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: '59000', }, deltaApplyTimeout: { envVarName: 'SUPERVISOR_DELTA_APPLY_TIMEOUT', varType: 'int', defaultValue: '0', }, 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, }, firewallMode: { envVarName: 'HOST_FIREWALL_MODE', varType: 'string', defaultValue: 'off', }, hostDiscoverability: { envVarName: 'HOST_DISCOVERABILITY', varType: 'bool', defaultValue: 'true', }, hardwareMetrics: { envVarName: 'SUPERVISOR_HARDWARE_METRICS', varType: 'bool', defaultValue: 'true', }, }; export const validKeys = [ 'SUPERVISOR_VPN_CONTROL', 'OVERRIDE_LOCK', ..._.map(configKeys, 'envVarName'), ]; const rateLimits: Dictionary<{ duration: number; lastAttempt: number | null; }> = { setVPNEnabled: { // Only try to switch the VPN once an hour duration: 60 * 60 * 1000, lastAttempt: null, }, }; async function getConfigBackends(): Promise { // Exit early if we already have a list if (configBackends.length > 0) { return configBackends; } // Get all the configurable backends this device supports const backends = await configUtils.getSupportedBackends(); // Initialize each backend for (const backend of backends) { await backend.initialise(); } // Return list of initialized ConfigBackends return backends; } export async function setTarget( target: Dictionary, trx?: db.Transaction, ): Promise { const $db = trx ?? db.models.bind(db); const formatted = formatConfigKeys(target); // check for legacy keys if (formatted['OVERRIDE_LOCK'] != null) { formatted['SUPERVISOR_OVERRIDE_LOCK'] = formatted['OVERRIDE_LOCK']; } const confToUpdate = { targetValues: JSON.stringify(formatted), }; await $db('deviceConfig').update(confToUpdate); } export async function getTarget({ initial = false, }: { initial?: boolean } = {}) { const [unmanaged, [devConfig]] = await Promise.all([ config.get('unmanaged'), db.models('deviceConfig').select('targetValues'), ]); let conf: Dictionary; try { conf = JSON.parse(devConfig.targetValues); } catch (e: any) { 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, _(configKeys).mapKeys('envVarName').mapValues('defaultValue').value(), ); return conf; } export async function getCurrent(): Promise> { // Build a Dictionary of currently set config values const currentConf: Dictionary = {}; // Get environment variables const conf = await config.getMany( ['deviceType'].concat(_.keys(configKeys)) as SchemaTypeKey[], ); // Add each value for (const key of _.keys(configKeys)) { const { envVarName } = configKeys[key]; const confValue = conf[key as SchemaTypeKey]; currentConf[envVarName] = confValue != null ? confValue.toString() : ''; } // Add VPN information currentConf['SUPERVISOR_VPN_CONTROL'] = (await isVPNEnabled()) ? 'true' : 'false'; // Get list of configurable backends const backends = await getConfigBackends(); // Add each backends configurable values for (const backend of backends) { Object.assign(currentConf, await getBootConfig(backend)); } // Return compiled configuration return currentConf; } export function formatConfigKeys(conf: { [key: string]: any; }): Dictionary { const namespaceRegex = /^BALENA_(.*)/; const legacyNamespaceRegex = /^RESIN_(.*)/; const confFromNamespace = configUtils.filterNamespaceFromConfig( namespaceRegex, conf, ); const confFromLegacyNamespace = configUtils.filterNamespaceFromConfig( legacyNamespaceRegex, conf, ); const noNamespaceConf = _.pickBy(conf, (_v, k) => { return !_.startsWith(k, 'RESIN_') && !_.startsWith(k, 'BALENA_'); }); const confWithoutNamespace = _.defaults( confFromNamespace, confFromLegacyNamespace, noNamespaceConf, ); return _.pickBy( confWithoutNamespace, (_v, k) => validKeys.includes(k) || matchesAnyBootConfig(k), ); } export function getDefaults() { return _.extend( { SUPERVISOR_VPN_CONTROL: 'true', }, _.mapValues(_.mapKeys(configKeys, 'envVarName'), 'defaultValue'), ); } export function resetRateLimits() { _.each(rateLimits, (action) => { action.lastAttempt = null; }); } export function bootConfigChangeRequired( configBackend: ConfigBackend, current: Dictionary, target: Dictionary, deviceType: string, ): boolean { const targetBootConfig = configUtils.envToBootConfig(configBackend, target); const currentBootConfig = configUtils.envToBootConfig(configBackend, current); // Some devices require specific overlays, here we apply them configBackend.ensureRequiredConfig(deviceType, targetBootConfig); // Search for any unsupported values _.each(targetBootConfig, (value, key) => { if ( !configBackend.isSupportedConfig(key) && currentBootConfig[key] !== value ) { const err = `Attempt to change blacklisted config value ${key}`; logger.logSystemMessage(err, { error: err }, 'Apply boot config error'); throw new Error(err); } }); if (!_.isEqual(currentBootConfig, targetBootConfig)) { // Check if the only difference is the targetBootConfig not containing a special case const SPECIAL_CASE = 'configuration'; // ODMDATA Mode for TX2 devices if (!(SPECIAL_CASE in targetBootConfig)) { // Create a copy to modify const targetCopy = structuredClone(targetBootConfig); // Add current value to simulate if the value was set in the cloud on provision targetCopy[SPECIAL_CASE] = currentBootConfig[SPECIAL_CASE]; if (_.isEqual(targetCopy, currentBootConfig)) { // This proves the only difference is ODMDATA configuration is not set in target config. // This special case is to allow devices that upgrade to SV with ODMDATA support // and have no set a ODMDATA configuration in the cloud yet. // Normally on provision this value would have been sent to the cloud. return false; // (no change is required) } } // Change is required because configs do not match return true; } // Return false (no change is required) return false; } function getConfigSteps( current: Dictionary, target: Dictionary, ) { const configChanges: Dictionary = {}; const humanReadableConfigChanges: Dictionary = {}; let reboot = false; const steps: ConfigStep[] = []; _.each( configKeys, ( { envVarName, varType, rebootRequired: $rebootRequired, defaultValue }, key, ) => { let changingValue: null | string = null; // Test if the key is different if (!configTest(varType, current[envVarName], target[envVarName])) { // Check that the difference is not due to the variable having an invalid // value set from the cloud if (config.valueIsValid(key as SchemaTypeKey, target[envVarName])) { // Save the change if it is both valid and different changingValue = target[envVarName]; } else { if (!configTest(varType, current[envVarName], defaultValue)) { const message = `Warning: Ignoring invalid device configuration value for ${key}, value: ${inspect( target[envVarName], )}. Falling back to default (${defaultValue})`; logger.logSystemMessage( message, { key: envVarName, value: target[envVarName] }, 'invalidDeviceConfig', false, ); // Set it to the default value if it is different to the current changingValue = defaultValue; } } if (changingValue != null) { configChanges[key] = changingValue; humanReadableConfigChanges[envVarName] = changingValue; reboot = $rebootRequired || reboot; } } }, ); if (!_.isEmpty(configChanges)) { if (reboot) { steps.push({ action: 'setRebootBreadcrumb' }); } steps.push({ action: 'changeConfig', target: configChanges, humanReadableTarget: humanReadableConfigChanges, }); } return steps; } async function getVPNSteps( current: Dictionary, target: Dictionary, ) { const { unmanaged } = await config.getMany(['unmanaged']); let steps: ConfigStep[] = []; // Check for special case actions for the VPN if ( !unmanaged && !_.isEmpty(target['SUPERVISOR_VPN_CONTROL']) && checkBoolChanged(current, target, 'SUPERVISOR_VPN_CONTROL') ) { steps.push({ action: 'setVPNEnabled', target: target['SUPERVISOR_VPN_CONTROL'], }); } // TODO: the only step that requires rate limiting is setVPNEnabled // do not use rate limiting in the future as it probably will change. // The reason rate limiting is needed for this step is because the dbus // API does not wait for the service response when a unit is started/stopped. // This would cause too many requests on systemd and a possible error. // Promisifying the dbus api to wait for the response would be the right solution const now = Date.now(); steps = _.map(steps, (step) => { const action = step.action; if (action in rateLimits) { const lastAttempt = rateLimits[action].lastAttempt; rateLimits[action].lastAttempt = now; // If this step should be rate limited, we replace it with a noop. // We do this instead of removing it, as we don't actually want the // state engine to think that it's successfully applied the target state, // as it won't reattempt the change until the target state changes if ( lastAttempt != null && Date.now() - lastAttempt < rateLimits[action].duration ) { return { action: 'noop' } as ConfigStep; } } return step; }); return steps; } async function getBackendSteps( current: Dictionary, target: Dictionary, ) { const steps: ConfigStep[] = []; const backends = await getConfigBackends(); const { deviceType } = await config.getMany(['deviceType']); // Check for required bootConfig changes for (const backend of backends) { if (changeRequired(backend, current, target, deviceType)) { steps.push({ action: 'setBootConfig', target, }); } } return [ // All backend steps require a reboot ...(steps.length > 0 ? [{ action: 'setRebootBreadcrumb' } as ConfigStep] : []), ...steps, ]; } async function isRebootRequired() { const hasBreadcrumb = await fsUtils.exists(REBOOT_BREADCRUMB); if (hasBreadcrumb) { const stats = await fs.stat(REBOOT_BREADCRUMB); // If the breadcrumb exists and the last modified time is greater than the // boot time, that means we need to reboot return stats.mtime.getTime() > fsUtils.getBootTime().getTime(); } return false; } export async function getRequiredSteps( currentState: { local?: { config?: EnvVarObject } }, targetState: { local?: { config: EnvVarObject } }, ): Promise { const current = currentState?.local?.config ?? {}; const target = targetState?.local?.config ?? {}; const configSteps = getConfigSteps(current, target); const steps = [ ...configSteps, ...(await getVPNSteps(current, target)), // Only apply backend steps if no more config changes are left since // changing config.json may restart the supervisor ...(configSteps.length > 0 && // if any config step is a not 'noop' step, skip the backend steps configSteps.filter((s) => s.action !== 'noop').length > 0 ? // Set a 'noop' action so the apply function knows to retry [{ action: 'noop' } as ConfigStep] : await getBackendSteps(current, target)), ]; // Check if there is either no steps, or they are all // noops, and we need to reboot. We want to do this // because in a preloaded setting with no internet // connection, the device will try to start containers // before any boot config has been applied, which can // cause problems const rebootRequired = await isRebootRequired(); if (_.every(steps, { action: 'noop' }) && rebootRequired) { steps.push({ action: 'reboot', }); } return steps; } function changeRequired( configBackend: ConfigBackend, currentConfig: Dictionary, targetConfig: Dictionary, deviceType: string, ): boolean { let aChangeIsRequired = false; try { aChangeIsRequired = bootConfigChangeRequired( configBackend, currentConfig, targetConfig, deviceType, ); } catch (e) { switch (e) { case 'Value missing from target configuration.': if (configBackend instanceof Odmdata) { // In this special case, devices with ODMDATA support may have // empty configuration options in the target if they upgraded to a SV // version with ODMDATA support and didn't set a value in the cloud. // If this is the case then we will update the cloud with the device's // current config and then continue without an error aChangeIsRequired = false; } else { log.debug(` The device has a configuration setting that the cloud does not have set.\nNo configurations for this backend will be set.`); // Set changeRequired to false so we do not get stuck in a loop trying to fix this mismatch aChangeIsRequired = false; } throw e; default: throw e; } } return aChangeIsRequired; } export function executeStepAction( step: ConfigStep, opts: DeviceActionExecutorOpts, ) { if (step.action !== 'reboot' && step.action !== 'noop') { return actionExecutors[step.action](step, opts); } } export function isValidAction(action: string): boolean { return _.keys(actionExecutors).includes(action); } export async function getBootConfig( backend: ConfigBackend | null, ): Promise { if (backend == null) { return {}; } const conf = await backend.getBootConfig(); return configUtils.bootConfigToEnv(backend, conf); } // Exported for tests export async function setBootConfig( backend: ConfigBackend | null, target: Dictionary, ) { if (backend == null) { return false; } const conf = configUtils.envToBootConfig(backend, target); logger.logSystemMessage( `Applying boot config: ${JSON.stringify(conf)}`, {}, 'Apply boot config in progress', ); // Ensure the required target config is available backend.ensureRequiredConfig(await config.get('deviceType'), conf); try { await backend.setBootConfig(conf); logger.logSystemMessage( `Applied boot config: ${JSON.stringify(conf)}`, {}, 'Apply boot config success', ); return true; } catch (err) { logger.logSystemMessage( `Error setting boot config: ${err}`, { error: err }, 'Apply boot config error', ); throw err; } } async function isVPNEnabled(): Promise { try { const activeState = await dbus.serviceActiveState(vpnServiceName); return !['inactive', 'deactivating'].includes(activeState); } catch (e: any) { if (UnitNotLoadedError(e)) { return false; } throw e; } } async function setVPNEnabled(value: string | boolean = true) { const enable = checkTruthy(value); if (enable) { await dbus.startService(vpnServiceName); } else { await dbus.stopService(vpnServiceName); } } function configTest(method: string, a: string, b: string): boolean { switch (method) { case 'bool': return checkTruthy(a) === checkTruthy(b); case 'int': return checkInt(a) === checkInt(b); case 'string': return a === b; default: throw new Error('Incorrect datatype passed to DeviceConfig.configTest'); } } function checkBoolChanged( current: Dictionary, target: Dictionary, key: string, ): boolean { return checkTruthy(current[key]) !== checkTruthy(target[key]); }