From 1e224be0cd167d7f037a3d1cb4b260b6ff36d458 Mon Sep 17 00:00:00 2001 From: Christina Ying Wang Date: Wed, 1 May 2024 01:44:15 -0700 Subject: [PATCH] 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 --- src/device-api/v1.ts | 31 --- src/host-config/proxy.ts | 93 +++++++++ src/host-config/types.ts | 26 +++ test/integration/device-api/v1.spec.ts | 45 ----- test/unit/host-config.spec.ts | 259 +++++++++++++++++++++++++ 5 files changed, 378 insertions(+), 76 deletions(-) create mode 100644 src/host-config/types.ts create mode 100644 test/unit/host-config.spec.ts diff --git a/src/device-api/v1.ts b/src/device-api/v1.ts index 27ff18e6..7d18dc43 100644 --- a/src/device-api/v1.ts +++ b/src/device-api/v1.ts @@ -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 */ } diff --git a/src/host-config/proxy.ts b/src/host-config/proxy.ts index c1ced84c..a4bb93e3 100644 --- a/src/host-config/proxy.ts +++ b/src/host-config/proxy.ts @@ -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 { + const parsedBlock: Record = {}; + + // 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 { try { const noProxy = await readFromBoot(noProxyPath, 'utf-8') diff --git a/src/host-config/types.ts b/src/host-config/types.ts new file mode 100644 index 00000000..180103b5 --- /dev/null +++ b/src/host-config/types.ts @@ -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; + +export const RedsocksConfig = t.partial({ + redsocks: ProxyConfig, +}); +export type RedsocksConfig = t.TypeOf; diff --git a/test/integration/device-api/v1.spec.ts b/test/integration/device-api/v1.spec.ts index fdcfd60d..f85b8cd7 100644 --- a/test/integration/device-api/v1.spec.ts +++ b/test/integration/device-api/v1.spec.ts @@ -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') diff --git a/test/unit/host-config.spec.ts b/test/unit/host-config.spec.ts new file mode 100644 index 00000000..e5406b6b --- /dev/null +++ b/test/unit/host-config.spec.ts @@ -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({}); + }); + }); +});