Refactor host-config to be its own module

The host-config module exposes the following interfaces: get,
patch, and parse.

`get` gets host configuration such as redsocks proxy configuration
and hostname and returns it in an object of type HostConfiguration.

`patch` takes an object of type HostConfiguration or LegacyHostConfiguration
and updates the hostname and redsocks proxy configuration, optionally
forcing the patch through update locks.

`parse` takes a user input of unknown type and parses it into type
HostConfiguration or LegacyHostConfiguration for patching, erroring if
parse was unsuccessful.

LegacyHostConfiguration is a looser typing of the user input which does
not validate values of the five known proxy fields of type, ip, port,
username, and password. We should stop supporting it in the next
major Supervisor API release.

Change-type: minor
Signed-off-by: Christina Ying Wang <christina@balena.io>
This commit is contained in:
Christina Ying Wang 2024-05-01 16:55:54 -07:00
parent be986a62a5
commit 53f5641ef1
9 changed files with 296 additions and 237 deletions

View File

@ -7,7 +7,10 @@ import * as deviceState from '../device-state';
import * as logger from '../logger';
import * as config from '../config';
import * as hostConfig from '../host-config';
import { parse } from '../host-config/index';
import type {
HostConfiguration,
LegacyHostConfiguration,
} from '../host-config';
import * as applicationManager from '../compose/application-manager';
import type { CompositionStepAction } from '../compose/composition-steps';
import { generateStep } from '../compose/composition-steps';
@ -447,8 +450,11 @@ export const getHostConfig = async () => {
* - PATCH /v1/device/host-config
*/
export const patchHostConfig = async (conf: unknown, force: boolean) => {
const parsedConf = parse(conf);
if (parsedConf) {
await hostConfig.patch(parsedConf, force);
let parsedConf: HostConfiguration | LegacyHostConfiguration;
try {
parsedConf = hostConfig.parse(conf);
} catch (e: unknown) {
throw new BadRequestError((e as Error).message);
}
await hostConfig.patch(parsedConf, force);
};

View File

@ -187,6 +187,12 @@ router.patch('/v1/device/host-config', async (req, res) => {
if (e instanceof UpdatesLockedError) {
return res.status(423).send(e?.message ?? e);
}
// User input cannot be parsed to type HostConfiguration or LegacyHostConfiguration
if (isBadRequestError(e)) {
return res.status(e.statusCode).send(e.statusMessage);
}
return res.status(503).send((e as Error)?.message ?? e ?? 'Unknown error');
}
});

View File

@ -1,201 +0,0 @@
import { stripIndent } from 'common-tags';
import _ from 'lodash';
import path from 'path';
import * as applicationManager from './compose/application-manager';
import {
readHostname,
setHostname,
readNoProxy,
setNoProxy,
} from './host-config/index';
import * as dbus from './lib/dbus';
import { isENOENT } from './lib/errors';
import { mkdirp, unlinkAll } from './lib/fs-utils';
import { writeToBoot, readFromBoot, pathOnBoot } from './lib/host-utils';
import * as updateLock from './lib/update-lock';
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 = pathOnBoot('system-proxy');
const redsocksConfPath = path.join(proxyBasePath, 'redsocks.conf');
interface ProxyConfig {
[key: string]: string | string[] | number;
}
interface HostConfig {
network: {
proxy?: ProxyConfig;
hostname?: string;
};
}
const isAuthField = (field: string): boolean =>
['login', 'password'].includes(field);
const memoizedAuthRegex = _.memoize(
(proxyField: string) => new RegExp(proxyField + '\\s*=\\s*"(.*)"\\s*;'),
);
const memoizedRegex = _.memoize(
// Add beginning-of-line RegExp to prevent local_ip and local_port static fields from being memoized
(proxyField) => new RegExp('^\\s*' + proxyField + '\\s*=\\s*([^;\\s]*)\\s*;'),
);
async function readProxy(): Promise<ProxyConfig | undefined> {
const conf: ProxyConfig = {};
let redsocksConf: string;
try {
redsocksConf = await readFromBoot(redsocksConfPath, 'utf-8');
} catch (e: unknown) {
if (!isENOENT(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];
}
}
}
const noProxy = await readNoProxy();
if (noProxy.length) {
conf.noProxy = noProxy;
}
// Convert port to number per API doc spec
if (conf.port) {
conf.port = parseInt(conf.port as string, 10);
}
return conf;
}
function generateRedsocksConfEntries(conf: ProxyConfig): string {
let val = '';
for (const field of proxyFields) {
let v = conf[field];
if (v != null) {
if (isAuthField(field)) {
// Escape any quotes in the field value
v = `"${v.toString().replace(/"/g, '\\"')}"`;
}
val += `\t${field} = ${v};\n`;
}
}
return val;
}
async function setProxy(maybeConf: ProxyConfig | null): Promise<void> {
if (_.isEmpty(maybeConf)) {
await unlinkAll(redsocksConfPath);
await setNoProxy([]);
} 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 (Array.isArray(conf.noProxy)) {
await setNoProxy(conf.noProxy);
}
let currentConf: ProxyConfig | undefined;
try {
currentConf = await readProxy();
} catch {
// Noop - current redsocks.conf does not exist
}
// If currentConf is undefined, the currentConf spread will be skipped.
// See: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-1.html#conditional-spreads-create-optional-properties
const redsocksConf = `
${redsocksHeader}\n
${generateRedsocksConfEntries({ ...currentConf, ...conf })}
${redsocksFooter}
`;
await writeToBoot(redsocksConfPath, redsocksConf);
}
// restart balena-proxy-config if it is loaded and NOT PartOf redsocks-conf.target
if (
(
await Promise.any([
dbus.servicePartOf('balena-proxy-config'),
dbus.servicePartOf('resin-proxy-config'),
])
).includes('redsocks-conf.target') === false
) {
await Promise.any([
dbus.restartService('balena-proxy-config'),
dbus.restartService('resin-proxy-config'),
]);
}
// restart redsocks if it is loaded and NOT PartOf redsocks-conf.target
if (
(await dbus.servicePartOf('redsocks')).includes('redsocks-conf.target') ===
false
) {
await dbus.restartService('redsocks');
}
}
export async function get(): Promise<HostConfig> {
return {
network: {
proxy: await readProxy(),
hostname: await readHostname(),
},
};
}
export async function patch(
conf: HostConfig,
force: boolean = false,
): Promise<void> {
const apps = await applicationManager.getCurrentApps();
const appIds = Object.keys(apps).map((strId) => parseInt(strId, 10));
// It's possible for appIds to be an empty array, but patch shouldn't fail
// as it's not dependent on there being any running user applications.
return updateLock.lock(appIds, { force }, async () => {
const promises: Array<Promise<void>> = [];
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));
}
}
await Promise.all(promises);
});
}

View File

@ -4,9 +4,13 @@ import Reporter from 'io-ts-reporters';
import type { RedsocksConfig, ProxyConfig } from './types';
import { HostConfiguration, LegacyHostConfiguration } from './types';
import { readProxy, setProxy } from './proxy';
import * as config from '../config';
// FIXME: The host-config module shouldn't be importing from compose
import * as applicationManager from '../compose/application-manager';
import { pathOnRoot } from '../lib/host-utils';
import log from '../lib/supervisor-console';
import * as updateLock from '../lib/update-lock';
export * from './proxy';
export * from './types';
@ -33,7 +37,7 @@ export async function setHostname(val: string) {
export function parse(
conf: unknown,
): HostConfiguration | LegacyHostConfiguration | null {
): HostConfiguration | LegacyHostConfiguration {
const decoded = HostConfiguration.decode(conf);
if (isRight(decoded)) {
@ -52,7 +56,7 @@ export function parse(
return legacyDecoded.right;
}
}
return null;
throw new Error('Could not parse host config input to a valid format');
}
export function patchProxy(
@ -80,3 +84,49 @@ export function patchProxy(
}
return patchedConf;
}
export async function patch(
conf: HostConfiguration | LegacyHostConfiguration,
force: boolean = false,
): Promise<void> {
const apps = await applicationManager.getCurrentApps();
const appIds = Object.keys(apps).map((strId) => parseInt(strId, 10));
if (conf.network.hostname != null) {
await setHostname(conf.network.hostname);
}
if (conf.network.proxy != null) {
const targetConf = conf.network.proxy;
// It's possible for appIds to be an empty array, but patch shouldn't fail
// as it's not dependent on there being any running user applications.
return updateLock.lock(appIds, { force }, async () => {
const proxyConf = await readProxy();
let currentConf: ProxyConfig | undefined = undefined;
if (proxyConf) {
delete proxyConf.noProxy;
currentConf = proxyConf;
}
// Merge current & target redsocks.conf
const patchedConf = patchProxy(
{
redsocks: currentConf,
},
{
redsocks: targetConf,
},
);
await setProxy(patchedConf, targetConf.noProxy);
});
}
}
export async function get(): Promise<HostConfiguration> {
return {
network: {
hostname: await readHostname(),
proxy: await readProxy(),
},
};
}

View File

@ -3,15 +3,17 @@ import path from 'path';
import { isRight } from 'fp-ts/lib/Either';
import Reporter from 'io-ts-reporters';
import type { RedsocksConfig, HostProxyConfig } from './types';
import { ProxyConfig } from './types';
import type { RedsocksConfig } from './types';
import { pathOnBoot, readFromBoot } from '../lib/host-utils';
import { unlinkAll } from '../lib/fs-utils';
import { pathOnBoot, readFromBoot, writeToBoot } from '../lib/host-utils';
import { unlinkAll, mkdirp } from '../lib/fs-utils';
import { isENOENT } from '../lib/errors';
import log from '../lib/supervisor-console';
import * as dbus from '../lib/dbus';
const proxyBasePath = pathOnBoot('system-proxy');
const noProxyPath = path.join(proxyBasePath, 'no_proxy');
const redsocksConfPath = path.join(proxyBasePath, 'redsocks.conf');
const disallowedProxyFields = ['local_ip', 'local_port'];
@ -136,6 +138,87 @@ export class RedsocksConf {
}
}
export async function readProxy(): Promise<HostProxyConfig | undefined> {
// Get and parse redsocks.conf
let rawConf: string | undefined;
try {
rawConf = await readFromBoot(redsocksConfPath, 'utf-8');
} catch (e: unknown) {
if (!isENOENT(e)) {
throw e;
}
return undefined;
}
const redsocksConf = RedsocksConf.parse(rawConf);
// Get and parse no_proxy
const noProxy = await readNoProxy();
// Build proxy object
const proxy = {
...redsocksConf.redsocks,
...(noProxy.length && { noProxy }),
};
// Assumes mandatory proxy config fields (type, ip, port) are present,
// even if they very well may not be. It is up to the user to ensure
// that all the necessary fields are present in the redsocks.conf file.
return proxy as HostProxyConfig;
}
export async function setProxy(
conf: RedsocksConfig,
noProxy: Nullable<string[]>,
) {
// Ensure proxy directory exists
await mkdirp(proxyBasePath);
// Set no_proxy
let noProxyChanged = false;
if (noProxy != null) {
noProxyChanged = await setNoProxy(noProxy);
}
// Write to redsocks.conf
const toWrite = RedsocksConf.stringify(conf);
if (toWrite) {
await writeToBoot(redsocksConfPath, toWrite);
}
// If target is empty aside from noProxy and noProxy got patched,
// do not change redsocks.conf to remain backwards compatible
else if (!noProxyChanged) {
await unlinkAll(redsocksConfPath);
}
// Restart services using dbus
await restartProxyServices();
}
async function restartProxyServices() {
// restart balena-proxy-config if it is loaded and NOT PartOf redsocks-conf.target
if (
(
await Promise.any([
dbus.servicePartOf('balena-proxy-config'),
dbus.servicePartOf('resin-proxy-config'),
])
).includes('redsocks-conf.target') === false
) {
await Promise.any([
dbus.restartService('balena-proxy-config'),
dbus.restartService('resin-proxy-config'),
]);
}
// restart redsocks if it is loaded and NOT PartOf redsocks-conf.target
if (
(await dbus.servicePartOf('redsocks')).includes('redsocks-conf.target') ===
false
) {
await dbus.restartService('redsocks');
}
}
export async function readNoProxy(): Promise<string[]> {
try {
const noProxy = await readFromBoot(noProxyPath, 'utf-8')
@ -155,10 +238,17 @@ export async function readNoProxy(): Promise<string[]> {
}
}
export async function setNoProxy(list: string[]) {
export async function setNoProxy(list: Nullable<string[]>) {
const current = await readNoProxy();
if (!list || !Array.isArray(list) || !list.length) {
await unlinkAll(noProxyPath);
} else {
await fs.writeFile(noProxyPath, list.join('\n'));
}
// If noProxy has changed, return true
return (
Array.isArray(list) &&
(current.length !== list.length ||
!current.every((addr) => list.includes(addr)))
);
}

View File

@ -1,17 +1,17 @@
base {
log_debug = off;
log_info = on;
log = stderr;
daemon = off;
redirector = iptables;
log_debug = off;
log_info = on;
log = stderr;
daemon = off;
redirector = iptables;
}
redsocks {
local_ip = 127.0.0.1;
local_port = 12345;
ip = example.org;
port = 1080;
type = socks5;
login = "foo";
password = "bar";
local_ip = 127.0.0.1;
local_port = 12345;
}

View File

@ -816,5 +816,41 @@ describe('device-api/v1', () => {
);
});
});
it('responds with 200 if patch successful', async () => {
(actions.patchHostConfig as SinonStub).resolves();
await request(api)
.patch('/v1/device/host-config')
.send({ network: { hostname: 'deadbeef' } })
.set('Authorization', `Bearer ${await apiKeys.getGlobalApiKey()}`)
.expect(200);
});
it('responds with 423 for update lock errors', async () => {
(actions.patchHostConfig as SinonStub).throws(new UpdatesLockedError());
await request(api)
.patch('/v1/device/host-config')
.send({ network: { hostname: 'deadbeef' } })
.set('Authorization', `Bearer ${await apiKeys.getGlobalApiKey()}`)
.expect(423);
});
it('responds with 400 for BadRequestErrors', async () => {
(actions.patchHostConfig as SinonStub).throws(new BadRequestError());
await request(api)
.patch('/v1/device/host-config')
.send({ network: { hostname: 'deadbeef' } })
.set('Authorization', `Bearer ${await apiKeys.getGlobalApiKey()}`)
.expect(400);
});
it('responds with 503 for other errors that occur during patch', async () => {
(actions.patchHostConfig as SinonStub).throws(new Error());
await request(api)
.patch('/v1/device/host-config')
.send({ network: { hostname: 'deadbeef' } })
.set('Authorization', `Bearer ${await apiKeys.getGlobalApiKey()}`)
.expect(503);
});
});
});

View File

@ -7,7 +7,7 @@ import { stub } from 'sinon';
import * as fs from 'fs/promises';
import { get, patch } from '~/src/host-config';
import * as hostConfig from '~/src/host-config/index';
import * as hostConfig from '~/src/host-config';
import * as config from '~/src/config';
import * as applicationManager from '~/src/compose/application-manager';
import type { InstancedAppState } from '~/src/compose/types';
@ -73,12 +73,14 @@ describe('host-config', () => {
// Stub external dependencies
stub(dbus, 'servicePartOf').resolves([]);
stub(dbus, 'restartService').resolves();
stub(applicationManager, 'getCurrentApps').resolves({});
});
afterEach(async () => {
await tFs.restore();
(dbus.servicePartOf as SinonStub).restore();
(dbus.restartService as SinonStub).restore();
(applicationManager.getCurrentApps as SinonStub).restore();
});
describe('hostname', () => {
@ -111,6 +113,43 @@ describe('host-config', () => {
expect(await fs.readFile(noProxy, 'utf-8')).to.equal(
'balena.io\n1.1.1.1',
);
await hostConfig.setNoProxy(['balena.io', '2.2.2.2']);
expect(await fs.readFile(noProxy, 'utf-8')).to.equal(
'balena.io\n2.2.2.2',
);
});
it('returns whether noProxy was changed', async () => {
// Set initial no_proxy as empty
await hostConfig.setNoProxy([]);
// Change no_proxy
expect(await hostConfig.setNoProxy(['balena.io', '1.1.1.1'])).to.be.true;
expect(await hostConfig.readNoProxy())
.to.deep.include.members(['balena.io', '1.1.1.1'])
.and.have.lengthOf(2);
// Change no_proxy to same value
expect(await hostConfig.setNoProxy(['1.1.1.1', 'balena.io'])).to.be.false;
expect(await hostConfig.readNoProxy())
.to.deep.include.members(['balena.io', '1.1.1.1'])
.and.have.lengthOf(2);
// Remove a value
expect(await hostConfig.setNoProxy(['1.1.1.1'])).to.be.true;
expect(await hostConfig.readNoProxy())
.to.deep.include.members(['1.1.1.1'])
.and.have.lengthOf(1);
// Add a value
expect(await hostConfig.setNoProxy(['2.2.2.2', '1.1.1.1'])).to.be.true;
expect(await hostConfig.readNoProxy())
.to.deep.include.members(['2.2.2.2', '1.1.1.1'])
.and.have.lengthOf(2);
// Remove no_proxy
expect(await hostConfig.setNoProxy([])).to.be.true;
expect(await hostConfig.readNoProxy()).to.deep.equal([]);
});
it('removes no_proxy file if empty or invalid', async () => {
@ -150,29 +189,36 @@ describe('host-config', () => {
});
it('prevents patch if update locks are present', async () => {
stub(applicationManager, 'getCurrentApps').resolves(currentApps);
(applicationManager.getCurrentApps as SinonStub).resolves(currentApps);
try {
await patch({ network: { hostname: 'test' } });
await patch({ network: { proxy: {} } });
expect.fail('Expected hostConfig.patch to throw UpdatesLockedError');
} catch (e: unknown) {
expect(e).to.be.instanceOf(UpdatesLockedError);
}
(applicationManager.getCurrentApps as SinonStub).restore();
});
it('patches if update locks are present but force is specified', async () => {
stub(applicationManager, 'getCurrentApps').resolves(currentApps);
(applicationManager.getCurrentApps as SinonStub).resolves(currentApps);
try {
await patch({ network: { hostname: 'deadreef' } }, true);
expect(await config.get('hostname')).to.equal('deadreef');
await patch({ network: { proxy: {} } }, true);
expect(await hostConfig.readProxy()).to.be.undefined;
} catch (e: unknown) {
expect.fail(`Expected hostConfig.patch to not throw, but got ${e}`);
}
});
(applicationManager.getCurrentApps as SinonStub).restore();
it('patches hostname regardless of update locks', async () => {
(applicationManager.getCurrentApps as SinonStub).resolves(currentApps);
try {
await patch({ network: { hostname: 'test' } });
expect(await config.get('hostname')).to.equal('test');
} catch (e: unknown) {
expect.fail(`Expected hostConfig.patch to not throw, but got ${e}`);
}
});
it('patches hostname', async () => {
@ -251,6 +297,19 @@ describe('host-config', () => {
]);
});
it('ignores unsupported fields when patching proxy', async () => {
const rawConf = await fs.readFile(redsocksConf, 'utf-8');
await patch({
network: {
proxy: {
local_ip: '127.0.0.2',
local_port: 12346,
} as any,
},
});
expect(await fs.readFile(redsocksConf, 'utf-8')).to.equal(rawConf);
});
it('skips restarting proxy services when part of redsocks-conf.target', async () => {
(dbus.servicePartOf as SinonStub).resolves(['redsocks-conf.target']);
await patch({
@ -298,7 +357,14 @@ describe('host-config', () => {
},
});
const { network } = await get();
expect(network).to.have.property('proxy');
// If only noProxy is patched, redsocks.conf should remain unchanged
expect(network).to.have.property('proxy').that.deep.includes({
ip: 'example.org',
port: 1080,
type: 'socks5',
login: 'foo',
password: 'bar',
});
expect(network.proxy).to.not.have.property('noProxy');
expect(await fs.readdir(proxyBase)).to.not.have.members(['no_proxy']);
});

View File

@ -2,9 +2,9 @@ import { expect } from 'chai';
import { stripIndent } from 'common-tags';
import type { SinonStub } from 'sinon';
import * as hostConfig from '~/src/host-config/index';
import { RedsocksConf } from '~/src/host-config/index';
import type { RedsocksConfig, ProxyConfig } from '~/src/host-config/types';
import * as hostConfig from '~/src/host-config';
import { RedsocksConf } from '~/src/host-config';
import type { RedsocksConfig, ProxyConfig } from '~/src/host-config';
import log from '~/lib/supervisor-console';
describe('RedsocksConf', () => {
@ -619,24 +619,30 @@ describe('src/host-config', () => {
(log.warn as SinonStub).resetHistory();
});
it('parses to null for HostConfiguration without network key', () => {
expect(hostConfig.parse({})).to.be.null;
it('throws error for HostConfiguration without network key', () => {
expect(() => hostConfig.parse({})).to.throw(
'Could not parse host config input to a valid format',
);
});
it('parses to null for HostConfiguration with invalid network key', () => {
it('throws error for HostConfiguration with invalid network key', () => {
const conf = {
network: 123,
};
expect(hostConfig.parse(conf)).to.be.null;
expect(() => hostConfig.parse(conf)).to.throw(
'Could not parse host config input to a valid format',
);
});
it('parses to null for HostConfiguration with invalid hostname', () => {
it('throws error for HostConfiguration with invalid hostname', () => {
const conf = {
network: {
hostname: 123,
},
};
expect(hostConfig.parse(conf)).to.be.null;
expect(() => hostConfig.parse(conf)).to.throw(
'Could not parse host config input to a valid format',
);
});
});
});