Add RedsocksConf.parse method

This is part of the host-config refactor which
enables easier encoding to / decoding from `redsocks.conf`.

Signed-off-by: Christina Ying Wang <christina@balena.io>
This commit is contained in:
Christina Ying Wang 2024-05-01 01:44:15 -07:00
parent 725d7790fb
commit 1e224be0cd
5 changed files with 378 additions and 76 deletions

View File

@ -6,7 +6,6 @@ import type { AuthorizedRequest } from '../lib/api-keys';
import * as eventTracker from '../event-tracker';
import type * as deviceState from '../device-state';
import * as constants from '../lib/constants';
import { checkInt, checkTruthy } from '../lib/validation';
import log from '../lib/supervisor-console';
import {
@ -16,8 +15,6 @@ import {
} from '../lib/errors';
import type { CompositionStepAction } from '../compose/composition-steps';
const disallowedHostConfigPatchFields = ['local_ip', 'local_port'];
export const router = express.Router();
router.post('/v1/restart', (req: AuthorizedRequest, res, next) => {
@ -176,34 +173,6 @@ router.patch('/v1/device/host-config', async (req, res) => {
// If network does not exist, skip all field validation checks below
throw new Error();
}
const { proxy } = req.body.network;
// Validate proxy fields, if they exist
if (proxy && Object.keys(proxy).length) {
const blacklistedFields = Object.keys(proxy).filter((key) =>
disallowedHostConfigPatchFields.includes(key),
);
if (blacklistedFields.length > 0) {
log.warn(`Invalid proxy field(s): ${blacklistedFields.join(', ')}`);
}
if (
proxy.type &&
!constants.validRedsocksProxyTypes.includes(proxy.type)
) {
log.warn(
`Invalid redsocks proxy type, must be one of ${constants.validRedsocksProxyTypes.join(
', ',
)}`,
);
}
if (proxy.noProxy && !Array.isArray(proxy.noProxy)) {
log.warn('noProxy field must be an array of addresses');
}
}
} catch (e) {
/* noop */
}

View File

@ -1,13 +1,106 @@
import { promises as fs } from 'fs';
import path from 'path';
import { isRight } from 'fp-ts/lib/Either';
import Reporter from 'io-ts-reporters';
import type { RedsocksConfig } from './types';
import { ProxyConfig } from './types';
import { pathOnBoot, readFromBoot } from '../lib/host-utils';
import { unlinkAll } from '../lib/fs-utils';
import { isENOENT } from '../lib/errors';
import log from '../lib/supervisor-console';
const proxyBasePath = pathOnBoot('system-proxy');
const noProxyPath = path.join(proxyBasePath, 'no_proxy');
const disallowedProxyFields = ['local_ip', 'local_port'];
const isAuthField = (field: string): boolean =>
['login', 'password'].includes(field);
// ? is a lazy operator, so only the contents up until the first `}(?=\s|$)` is matched.
// (?=\s|$) indicates that `}` must be followed by a whitespace or end of file to match,
// in case there are user fields with brackets such as login or password fields.
const blockRegexFor = (blockLabel: string) =>
new RegExp(`${blockLabel}\\s?{([\\s\\S]+?)}(?=\\s|$)`);
export class RedsocksConf {
// public static stringify(_config: RedsocksConfig): string {
// return 'TODO';
// }
public static parse(rawConf: string): RedsocksConfig {
const conf: RedsocksConfig = {};
rawConf = rawConf.trim();
if (rawConf.length === 0) {
return conf;
}
// Extract contents of `redsocks {...}` using regex
const rawRedsocksBlockMatch = rawConf.match(blockRegexFor('redsocks'));
// No group was captured, indicating malformed config
if (!rawRedsocksBlockMatch) {
log.warn('Invalid redsocks block in redsocks.conf');
return conf;
}
const rawRedsocksBlock = RedsocksConf.parseBlock(
rawRedsocksBlockMatch[1],
disallowedProxyFields,
);
const maybeProxyConfig = ProxyConfig.decode(rawRedsocksBlock);
if (isRight(maybeProxyConfig)) {
conf.redsocks = {
...maybeProxyConfig.right,
};
return conf;
} else {
log.warn(
['Invalid redsocks block in redsocks.conf:']
.concat(Reporter.report(maybeProxyConfig))
.join('\n'),
);
return {};
}
}
/**
* Given the raw contents of a block redsocks.conf file,
* extract to a key-value object.
*/
private static parseBlock(
rawBlockConf: string,
unsupportedKeys: string[],
): Record<string, string> {
const parsedBlock: Record<string, string> = {};
// Split by newline and optional semicolon
for (const line of rawBlockConf.split(/;?\n/)) {
if (!line.trim().length) {
continue;
}
let [key, value] = line.split(/ *?= *?/).map((s) => s.trim());
// Don't parse unsupported keys
if (key && unsupportedKeys.some((k) => key.match(k))) {
continue;
}
if (key && value) {
if (isAuthField(key)) {
// Remove double quotes from login and password fields for readability
value = value.replace(/"/g, '');
}
parsedBlock[key] = value;
} else {
// Skip malformed lines
log.warn(
`Ignoring malformed redsocks.conf line ${isAuthField(key) ? `"${key}"` : `"${line.trim()}"`} due to missing key, value, or "="`,
);
}
}
return parsedBlock;
}
}
export async function readNoProxy(): Promise<string[]> {
try {
const noProxy = await readFromBoot(noProxyPath, 'utf-8')

26
src/host-config/types.ts Normal file
View File

@ -0,0 +1,26 @@
import * as t from 'io-ts';
import { NumericIdentifier } from '../types';
export const ProxyConfig = t.intersection([
t.type({
type: t.union([
t.literal('socks4'),
t.literal('socks5'),
t.literal('http-connect'),
t.literal('http-relay'),
]),
ip: t.string,
port: NumericIdentifier,
}),
// login & password are optional fields
t.partial({
login: t.string,
password: t.string,
}),
]);
export type ProxyConfig = t.TypeOf<typeof ProxyConfig>;
export const RedsocksConfig = t.partial({
redsocks: ProxyConfig,
});
export type RedsocksConfig = t.TypeOf<typeof RedsocksConfig>;

View File

@ -18,7 +18,6 @@ import {
BadRequestError,
} from '~/lib/errors';
import log from '~/lib/supervisor-console';
import * as constants from '~/lib/constants';
// All routes that require Authorization are integration tests due to
// the api-key module relying on the database.
@ -805,50 +804,6 @@ describe('device-api/v1', () => {
before(() => stub(actions, 'patchHostConfig'));
after(() => (actions.patchHostConfig as SinonStub).restore());
const validProxyReqs: { [key: string]: number[] | string[] } = {
ip: ['proxy.example.org', 'proxy.foo.org'],
port: [5128, 1080],
type: constants.validRedsocksProxyTypes,
login: ['user', 'user2'],
password: ['foo', 'bar'],
};
it('warns on the supervisor console when provided disallowed proxy fields', async () => {
const invalidProxyReqs: { [key: string]: string | number } = {
// At this time, don't support changing local_ip or local_port
local_ip: '0.0.0.0',
local_port: 12345,
type: 'invalidType',
noProxy: 'not a list of addresses',
};
for (const key of Object.keys(invalidProxyReqs)) {
await request(api)
.patch('/v1/device/host-config')
.set('Authorization', `Bearer ${await apiKeys.getGlobalApiKey()}`)
.send({ network: { proxy: { [key]: invalidProxyReqs[key] } } })
.expect(200)
.then(() => {
if (key === 'type') {
expect(log.warn as SinonStub).to.have.been.calledWith(
`Invalid redsocks proxy type, must be one of ${validProxyReqs.type.join(
', ',
)}`,
);
} else if (key === 'noProxy') {
expect(log.warn as SinonStub).to.have.been.calledWith(
'noProxy field must be an array of addresses',
);
} else {
expect(log.warn as SinonStub).to.have.been.calledWith(
`Invalid proxy field(s): ${key}`,
);
}
});
(log.warn as SinonStub).reset();
}
});
it('warns on console when sent a malformed patch body', async () => {
await request(api)
.patch('/v1/device/host-config')

View File

@ -0,0 +1,259 @@
import { expect } from 'chai';
import { stripIndent } from 'common-tags';
import type { SinonStub } from 'sinon';
import * as hostConfig from '~/src/host-config/index';
import log from '~/lib/supervisor-console';
describe('RedsocksConf', () => {
describe('parse', () => {
it('parses config string into RedsocksConfig', () => {
const redsocksConfStr = stripIndent`
base {
log_debug = off;
log_info = on;
log = stderr;
daemon = off;
redirector = iptables;
}
redsocks {
local_ip = 127.0.0.1;
local_port = 12345;
type = socks5;
ip = example.org;
port = 1080;
login = "foo";
password = "bar";
}
dnstc {
test = test;
}
`;
const conf = hostConfig.RedsocksConf.parse(redsocksConfStr);
expect(conf).to.deep.equal({
redsocks: {
type: 'socks5',
ip: 'example.org',
port: 1080,
login: 'foo',
password: 'bar',
},
});
});
it("parses `redsocks {...}` config block no matter what position it's in or how many newlines surround it", () => {
const redsocksConfStr = stripIndent`
dnsu2t {
test = test;
}
redsocks {
local_ip = 127.0.0.1;
local_port = 12345;
type = http-connect;
ip = {test2}.balenadev.io;
port = 1082;
login = "us}{er";
password = "p{}a}}s{{s";
}
base {
log_debug = off;
log_info = on;
log = stderr;
daemon = off;
redirector = iptables;
}
`;
const conf = hostConfig.RedsocksConf.parse(redsocksConfStr);
expect(conf).to.deep.equal({
redsocks: {
type: 'http-connect',
ip: '{test2}.balenadev.io',
port: 1082,
login: 'us}{er',
password: 'p{}a}}s{{s',
},
});
const redsocksConfStr2 = stripIndent`
base {
log_debug = off;
log_info = on;
log = stderr;
daemon = off;
redirector = iptables;
}
redsocks {
local_ip = 127.0.0.1;
local_port = 12345;
type = http-connect;
ip = {test2}.balenadev.io;
port = 1082;
login = "us}{er";
password = "p{}a}}s{{s";
}`; // No newlines
const conf2 = hostConfig.RedsocksConf.parse(redsocksConfStr2);
expect(conf2).to.deep.equal({
redsocks: {
type: 'http-connect',
ip: '{test2}.balenadev.io',
port: 1082,
login: 'us}{er',
password: 'p{}a}}s{{s',
},
});
});
it('parses and removes double quotes around auth fields if present', async () => {
const expected = {
redsocks: {
ip: 'example.org',
login: 'user',
password: 'pass',
port: 1080,
type: 'socks5',
},
};
const confStr = stripIndent`
redsocks {
type = socks5;
ip = example.org;
port = 1080;
login = user;
password = pass;
}
`;
const conf = hostConfig.RedsocksConf.parse(confStr);
expect(conf).to.deep.equal(expected);
const confStr2 = stripIndent`
redsocks {
type = socks5;
ip = example.org;
port = 1080;
login = "user;
password = pass";
}
`;
const conf2 = hostConfig.RedsocksConf.parse(confStr2);
expect(conf2).to.deep.equal(expected);
const confStr3 = stripIndent`
redsocks {
type = socks5;
ip = example.org;
port = 1080;
login = "user";
password = "pass";
}
`;
const conf3 = hostConfig.RedsocksConf.parse(confStr3);
expect(conf3).to.deep.equal(expected);
});
it('parses to empty redsocks config with warnings while any values are invalid', () => {
const redsocksConfStr = stripIndent`
redsocks {
local_ip = 123;
local_port = foo;
type = socks6;
ip = 456;
port = bar;
login = user;
password = pass;
invalid_field = invalid_value;
}
`;
(log.warn as SinonStub).resetHistory();
const conf = hostConfig.RedsocksConf.parse(redsocksConfStr);
expect((log.warn as SinonStub).lastCall.args[0]).to.equal(
'Invalid redsocks block in redsocks.conf:\n' +
'Expecting NumericIdentifier at 0.port but instead got: "bar" (must be be an positive integer)\n' +
'Expecting one of:\n' +
' "socks4"\n' +
' "socks5"\n' +
' "http-connect"\n' +
' "http-relay"\n' +
'at 0.type but instead got: "socks6"',
);
(log.warn as SinonStub).resetHistory();
expect(conf).to.deep.equal({});
});
it('parses to empty config with warnings while some key-value pairs are malformed', () => {
// Malformed key-value pairs are pairs that are missing a key, value, or "="
const redsocksConfStr = stripIndent`
base {
log_debug off;
log_info = on
= stderr;
daemon = ;
redirector = iptables;
}
redsocks {
local_ip 127.0.0.1;
local_port = 12345
= socks5;
ip = ;
= 1080;
login =;
password = "bar";
}
`;
(log.warn as SinonStub).resetHistory();
const conf = hostConfig.RedsocksConf.parse(redsocksConfStr);
expect(
(log.warn as SinonStub).getCalls().map((call) => call.firstArg),
).to.deep.equal([
'Ignoring malformed redsocks.conf line "= socks5" due to missing key, value, or "="',
'Ignoring malformed redsocks.conf line "ip =" due to missing key, value, or "="',
'Ignoring malformed redsocks.conf line "= 1080" due to missing key, value, or "="',
'Ignoring malformed redsocks.conf line "login" due to missing key, value, or "="',
'Invalid redsocks block in redsocks.conf:\n' +
'Expecting string at 0.ip but instead got: undefined\n' +
'Expecting number at 0.port.0 but instead got: undefined\n' +
'Expecting string at 0.port.1 but instead got: undefined\n' +
'Expecting one of:\n' +
' "socks4"\n' +
' "socks5"\n' +
' "http-connect"\n' +
' "http-relay"\n' +
'at 0.type but instead got: undefined',
]);
(log.warn as SinonStub).resetHistory();
expect(conf).to.deep.equal({});
});
it('parses to empty config with warnings when a block is empty', () => {
const redsocksConfStr = stripIndent`
base {
}
redsocks {
}
`;
(log.warn as SinonStub).resetHistory();
const conf = hostConfig.RedsocksConf.parse(redsocksConfStr);
expect(
(log.warn as SinonStub).getCalls().map((call) => call.firstArg),
).to.deep.equal([
'Invalid redsocks block in redsocks.conf:\n' +
'Expecting string at 0.ip but instead got: undefined\n' +
'Expecting number at 0.port.0 but instead got: undefined\n' +
'Expecting string at 0.port.1 but instead got: undefined\n' +
'Expecting one of:\n' +
' "socks4"\n' +
' "socks5"\n' +
' "http-connect"\n' +
' "http-relay"\n' +
'at 0.type but instead got: undefined',
]);
(log.warn as SinonStub).resetHistory();
expect(conf).to.deep.equal({});
});
});
});