mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2024-12-30 02:28:53 +00:00
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:
parent
725d7790fb
commit
1e224be0cd
@ -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 */
|
||||
}
|
||||
|
@ -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
26
src/host-config/types.ts
Normal 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>;
|
@ -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')
|
||||
|
259
test/unit/host-config.spec.ts
Normal file
259
test/unit/host-config.spec.ts
Normal 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({});
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user