mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2025-01-10 15:02:49 +00:00
28c5a44e71
Controlled by BALENA_HOST_FIREWALL_MODE, the firewall can either be 'on' or 'off'. - In the 'off' state, all traffic is allowed. - In the 'on' state, only traffic for the core services provided by Balena is allowed. Change-type: patch Signed-off-by: Rich Bayliss <rich@balena.io>
333 lines
9.3 KiB
TypeScript
333 lines
9.3 KiB
TypeScript
import { EventEmitter } from 'events';
|
|
import { Transaction } from 'knex';
|
|
import * as _ from 'lodash';
|
|
import StrictEventEmitter from 'strict-event-emitter-types';
|
|
import { inspect } from 'util';
|
|
import { generateUniqueKey } from '../lib/register-device';
|
|
|
|
import { Either, isLeft, isRight, Right } from 'fp-ts/lib/Either';
|
|
import * as t from 'io-ts';
|
|
|
|
import ConfigJsonConfigBackend from './configJson';
|
|
|
|
import * as FnSchema from './functions';
|
|
import * as Schema from './schema';
|
|
import { SchemaReturn, SchemaTypeKey, schemaTypes } from './schema-type';
|
|
|
|
import * as db from '../db';
|
|
import {
|
|
ConfigurationValidationError,
|
|
InternalInconsistencyError,
|
|
} from '../lib/errors';
|
|
|
|
export type ConfigMap<T extends SchemaTypeKey> = {
|
|
[key in T]: SchemaReturn<key>;
|
|
};
|
|
export type ConfigChangeMap<T extends SchemaTypeKey> = {
|
|
[key in T]?: SchemaReturn<key>;
|
|
};
|
|
|
|
// Export this type renamed, for storing config keys
|
|
export type ConfigKey = SchemaTypeKey;
|
|
export type ConfigType<T extends ConfigKey> = SchemaReturn<T>;
|
|
|
|
interface ConfigEventTypes {
|
|
change: ConfigChangeMap<SchemaTypeKey>;
|
|
}
|
|
|
|
export const configJsonBackend: ConfigJsonConfigBackend = new ConfigJsonConfigBackend(
|
|
Schema.schema,
|
|
);
|
|
|
|
type ConfigEventEmitter = StrictEventEmitter<EventEmitter, ConfigEventTypes>;
|
|
class ConfigEvents extends (EventEmitter as new () => ConfigEventEmitter) {}
|
|
const events = new ConfigEvents();
|
|
|
|
// Expose methods which make this module act as an EventEmitter
|
|
export const on: typeof events['on'] = events.on.bind(events);
|
|
export const once: typeof events['once'] = events.once.bind(events);
|
|
export const removeListener: typeof events['removeListener'] = events.removeListener.bind(
|
|
events,
|
|
);
|
|
|
|
export async function get<T extends SchemaTypeKey>(
|
|
key: T,
|
|
trx?: Transaction,
|
|
): Promise<SchemaReturn<T>> {
|
|
const $db = trx || db.models.bind(db);
|
|
|
|
if (Schema.schema.hasOwnProperty(key)) {
|
|
const schemaKey = key as Schema.SchemaKey;
|
|
|
|
return getSchema(schemaKey, $db).then((value) => {
|
|
if (value == null) {
|
|
const defaultValue = schemaTypes[key].default;
|
|
if (defaultValue instanceof t.Type) {
|
|
// The only reason that this would be the case in a non-function
|
|
// schema key is for the meta nullOrUndefined value. We check this
|
|
// by first decoding the value undefined with the default type, and
|
|
// then return undefined
|
|
const maybeDecoded = (defaultValue as t.Type<any>).decode(undefined);
|
|
|
|
return (
|
|
checkValueDecode(maybeDecoded, key, undefined) && maybeDecoded.right
|
|
);
|
|
}
|
|
return defaultValue as SchemaReturn<T>;
|
|
}
|
|
const decoded = decodeSchema(schemaKey, value);
|
|
|
|
// The following function will throw if the value
|
|
// is not correct, so we chain it this way to keep
|
|
// the type system happy
|
|
return checkValueDecode(decoded, key, value) && decoded.right;
|
|
});
|
|
} else if (FnSchema.fnSchema.hasOwnProperty(key)) {
|
|
const fnKey = key as FnSchema.FnSchemaKey;
|
|
// Cast the promise as something that produces an unknown, and this means that
|
|
// we can validate the output of the function as well, ensuring that the type matches
|
|
const promiseValue = FnSchema.fnSchema[fnKey]();
|
|
return promiseValue.then((value: unknown) => {
|
|
const decoded = schemaTypes[key].type.decode(value);
|
|
|
|
return checkValueDecode(decoded, key, value) && decoded.right;
|
|
});
|
|
} else {
|
|
throw new Error(`Unknown config value ${key}`);
|
|
}
|
|
}
|
|
|
|
export async function getMany<T extends SchemaTypeKey>(
|
|
keys: T[],
|
|
trx?: Transaction,
|
|
): Promise<{ [key in T]: SchemaReturn<key> }> {
|
|
const values = await Promise.all(keys.map((k) => get(k, trx)));
|
|
return (_.zipObject(keys, values) as unknown) as Promise<
|
|
{ [key in T]: SchemaReturn<key> }
|
|
>;
|
|
}
|
|
|
|
export async function set<T extends SchemaTypeKey>(
|
|
keyValues: ConfigMap<T>,
|
|
trx?: Transaction,
|
|
): Promise<void> {
|
|
const setValuesInTransaction = async (tx: Transaction) => {
|
|
const configJsonVals: Dictionary<unknown> = {};
|
|
const dbVals: Dictionary<unknown> = {};
|
|
|
|
_.each(keyValues, (v, k: T) => {
|
|
const schemaKey = k as Schema.SchemaKey;
|
|
const source = Schema.schema[schemaKey].source;
|
|
|
|
switch (source) {
|
|
case 'config.json':
|
|
configJsonVals[schemaKey] = v;
|
|
break;
|
|
case 'db':
|
|
dbVals[schemaKey] = v;
|
|
break;
|
|
default:
|
|
throw new Error(
|
|
`Unknown configuration source: ${source} for config key: ${k}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
const dbKeys = _.keys(dbVals) as T[];
|
|
const oldValues = await getMany(dbKeys, tx);
|
|
await Promise.all(
|
|
dbKeys.map(async (key: T) => {
|
|
const value = dbVals[key];
|
|
|
|
// if we have anything other than a string, it must be converted to
|
|
// a string before being stored in the db
|
|
const strValue = valueToString(value, key);
|
|
|
|
if (oldValues[key] !== value) {
|
|
await db.upsertModel('config', { key, value: strValue }, { key }, tx);
|
|
}
|
|
}),
|
|
);
|
|
|
|
if (!_.isEmpty(configJsonVals)) {
|
|
await configJsonBackend.set(
|
|
configJsonVals as {
|
|
[name in Schema.SchemaKey]: unknown;
|
|
},
|
|
);
|
|
}
|
|
};
|
|
|
|
// Firstly validate and coerce all of the types as
|
|
// they are being set
|
|
keyValues = validateConfigMap(keyValues);
|
|
|
|
if (trx != null) {
|
|
await setValuesInTransaction(trx);
|
|
} else {
|
|
await db.transaction((tx: Transaction) => setValuesInTransaction(tx));
|
|
}
|
|
events.emit('change', keyValues as ConfigMap<SchemaTypeKey>);
|
|
}
|
|
|
|
export async function remove<T extends Schema.SchemaKey>(
|
|
key: T,
|
|
): Promise<void> {
|
|
if (Schema.schema[key] == null || !Schema.schema[key].mutable) {
|
|
throw new Error(`Attempt to delete non-existent or immutable key ${key}`);
|
|
}
|
|
if (Schema.schema[key].source === 'config.json') {
|
|
return configJsonBackend.remove(key);
|
|
} else if (Schema.schema[key].source === 'db') {
|
|
await db.models('config').del().where({ key });
|
|
} else {
|
|
throw new Error(
|
|
`Unknown or unsupported config backend: ${Schema.schema[key].source}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function regenerateRegistrationFields(): Promise<void> {
|
|
await set({
|
|
uuid: newUniqueKey(),
|
|
deviceApiKey: newUniqueKey(),
|
|
});
|
|
}
|
|
|
|
export function newUniqueKey(): string {
|
|
return generateUniqueKey();
|
|
}
|
|
|
|
export function valueIsValid<T extends SchemaTypeKey>(
|
|
key: T,
|
|
value: unknown,
|
|
): boolean {
|
|
// If the default entry in the schema is a type and not a value,
|
|
// use this in the validation of the value
|
|
const schemaTypesEntry = schemaTypes[key as SchemaTypeKey];
|
|
let type: t.Type<unknown>;
|
|
if (schemaTypesEntry.default instanceof t.Type) {
|
|
type = t.union([schemaTypesEntry.type, schemaTypesEntry.default]);
|
|
} else {
|
|
type = schemaTypesEntry.type;
|
|
}
|
|
|
|
return isRight(type.decode(value));
|
|
}
|
|
|
|
async function getSchema<T extends Schema.SchemaKey>(
|
|
key: T,
|
|
$db: Transaction,
|
|
): Promise<unknown> {
|
|
let value: unknown;
|
|
switch (Schema.schema[key].source) {
|
|
case 'config.json':
|
|
value = await configJsonBackend.get(key);
|
|
break;
|
|
case 'db':
|
|
const [conf] = await $db('config').select('value').where({ key });
|
|
if (conf != null) {
|
|
return conf.value;
|
|
}
|
|
break;
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
function decodeSchema<T extends Schema.SchemaKey>(
|
|
key: T,
|
|
value: unknown,
|
|
): Either<t.Errors, SchemaReturn<T>> {
|
|
return schemaTypes[key].type.decode(value);
|
|
}
|
|
|
|
function validateConfigMap<T extends SchemaTypeKey>(
|
|
configMap: ConfigMap<T>,
|
|
): ConfigMap<T> {
|
|
// Just loop over every value, run the decode function, and
|
|
// throw if any value fails verification
|
|
return _.mapValues(configMap, (value, key) => {
|
|
if (
|
|
!Schema.schema.hasOwnProperty(key) ||
|
|
!Schema.schema[key as Schema.SchemaKey].mutable
|
|
) {
|
|
throw new Error(
|
|
`Attempt to set value for non-mutable schema key: ${key}`,
|
|
);
|
|
}
|
|
|
|
// If the default entry in the schema is a type and not a value,
|
|
// use this in the validation of the value
|
|
const schemaTypesEntry = schemaTypes[key as SchemaTypeKey];
|
|
let type: t.Type<unknown>;
|
|
if (schemaTypesEntry.default instanceof t.Type) {
|
|
type = t.union([schemaTypesEntry.type, schemaTypesEntry.default]);
|
|
} else {
|
|
type = schemaTypesEntry.type;
|
|
}
|
|
|
|
const decoded = type.decode(value);
|
|
if (isLeft(decoded)) {
|
|
throw new TypeError(
|
|
`Cannot set value for ${key}, as value failed validation: ${inspect(
|
|
value,
|
|
{ depth: Infinity },
|
|
)}`,
|
|
);
|
|
}
|
|
return decoded.right;
|
|
}) as ConfigMap<T>;
|
|
}
|
|
|
|
export async function generateRequiredFields() {
|
|
return getMany(['uuid', 'deviceApiKey', 'apiSecret', 'unmanaged']).then(
|
|
({ uuid, deviceApiKey, apiSecret, unmanaged }) => {
|
|
// These fields need to be set regardless
|
|
if (uuid == null || apiSecret == null) {
|
|
uuid = uuid || newUniqueKey();
|
|
apiSecret = apiSecret || newUniqueKey();
|
|
}
|
|
return set({ uuid, apiSecret }).then(() => {
|
|
if (unmanaged) {
|
|
return;
|
|
}
|
|
if (!deviceApiKey) {
|
|
return set({ deviceApiKey: newUniqueKey() });
|
|
}
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
function valueToString(value: unknown, name: string) {
|
|
switch (typeof value) {
|
|
case 'object':
|
|
return JSON.stringify(value);
|
|
case 'number':
|
|
case 'string':
|
|
case 'boolean':
|
|
return value.toString();
|
|
default:
|
|
throw new InternalInconsistencyError(
|
|
`Could not convert configuration value to string for storage, name: ${name}, value: ${value}, type: ${typeof value}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function checkValueDecode(
|
|
decoded: Either<t.Errors, unknown>,
|
|
key: string,
|
|
value: unknown,
|
|
): decoded is Right<unknown> {
|
|
if (isLeft(decoded)) {
|
|
throw new ConfigurationValidationError(key, value);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export const initialized = (async () => {
|
|
await db.initialized;
|
|
await generateRequiredFields();
|
|
})();
|