Merge pull request #2284 from balena-os/enable-redsocks-dnsu2t

Enable redsocks dnsu2t
This commit is contained in:
flowzone-app[bot] 2024-08-28 22:46:17 +00:00 committed by GitHub
commit da6f4bdbaf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 1077 additions and 240 deletions

View File

@ -568,7 +568,8 @@ By default, with [balenaOS 2.82.6](https://github.com/balena-os/meta-balena/blob
"port": 8123,
"login": "username",
"password": "password",
"noProxy": [ "152.10.30.4", "253.1.1.0/16" ]
"noProxy": [ "152.10.30.4", "253.1.1.0/16" ],
"dns": "1.1.1.1:53"
},
"hostname": "mynewhostname",
"force": true
@ -585,7 +586,9 @@ Keep in mind that, even if transparent proxy redirection will take effect immedi
The `noProxy` setting for the proxy is an optional array of IP addresses/subnets that should not be routed through the
proxy. Keep in mind that local/reserved subnets are already [excluded by balenaOS automatically](https://github.com/balena-os/meta-balena/blob/master/meta-balena-common/recipes-connectivity/balena-proxy-config/balena-proxy-config/balena-proxy-config).
If either `proxy` or `hostname` are null or empty values (i.e. `{}` for proxy or an empty string for hostname), they will be cleared to their default values (i.e. not using a proxy, and a hostname equal to the first 7 characters of the device's uuid, respectively).
As of v16.6.0, the Supervisor supports configuring the `dnsu2t` plugin for redsocks via the `dns` subfield under `proxy`. Only the `remote_ip` and `remote_port` fields are allowed to be modified. The input must be of type `boolean` or `string`. If boolean and `true`, the remote values will be the default, `8.8.8.8:53`. If boolean and false, the configuration will be removed. If string, it should be in the format `ADDRESS:PORT`, with `ADDRESS` and `PORT` both required. You may not configure `dnsu2t` without a redsocks proxy configuration, as `dnsu2t` depends on a working proxy to function.
If any of `proxy`, `dns`, or `hostname` are nullish, falsey, or empty values (i.e. `{}` for proxy, `false` for dns, or an empty string for hostname), they will be cleared to their default settings (i.e. not using a proxy, not using dns, and a hostname equal to the first 7 characters of the device's uuid, respectively).
#### Examples:
From an app container:
@ -636,7 +639,8 @@ Response:
"port":"8123",
"type":"socks5"
},
"hostname":"27b0fdc"
"hostname":"27b0fdc",
"dns": "1.1.1.1:53"
}
}
```

View File

@ -51,9 +51,15 @@ export function parse(
const legacyDecoded = LegacyHostConfiguration.decode(conf);
if (isRight(legacyDecoded)) {
return legacyDecoded.right;
} else {
const formattedErrorMessage = [
'Could not parse host config input to a valid format:',
]
.concat(Reporter.report(legacyDecoded))
.join('\n');
throw new Error(formattedErrorMessage);
}
}
throw new Error('Could not parse host config input to a valid format');
}
function patchProxy(

View File

@ -3,8 +3,8 @@ 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, HostProxyConfig, DnsInput } from './types';
import { ProxyConfig, DnsConfig } from './types';
import { pathOnBoot, readFromBoot, writeToBoot } from '../lib/host-utils';
import { unlinkAll, mkdirp } from '../lib/fs-utils';
import { isENOENT } from '../lib/errors';
@ -17,6 +17,9 @@ const redsocksConfPath = path.join(proxyBasePath, 'redsocks.conf');
const disallowedProxyFields = ['local_ip', 'local_port'];
const DEFAULT_REMOTE_IP = '8.8.8.8';
const DEFAULT_REMOTE_PORT = 53;
const isAuthField = (field: string): boolean =>
['login', 'password'].includes(field);
@ -38,18 +41,45 @@ export class RedsocksConf {
public static stringify(config: RedsocksConfig): string {
const blocks: string[] = [];
if (config.redsocks && Object.keys(config.redsocks).length > 0) {
blocks.push(RedsocksConf.stringifyBlock('base', baseBlock));
blocks.push(
RedsocksConf.stringifyBlock('redsocks', {
...config.redsocks,
local_ip: '127.0.0.1',
local_port: 12345,
}),
);
// If no redsocks config is provided or dns is the only config, return empty string.
// A dns-only config is not valid as it depends on proxy being configured to function.
if (
!config.redsocks ||
!Object.keys(config.redsocks).length ||
(Object.keys(config.redsocks).length === 1 &&
Object.hasOwn(config.redsocks, 'dns'))
) {
return '';
}
return blocks.length ? blocks.join('\n') : '';
// Add base block
blocks.push(RedsocksConf.stringifyBlock('base', baseBlock));
const { dns, ...redsocks } = config.redsocks;
// Add redsocks block
blocks.push(
RedsocksConf.stringifyBlock('redsocks', {
...redsocks,
local_ip: '127.0.0.1',
local_port: 12345,
}),
);
// Add optional dnsu2t block if input dns config is true or a string
if (dns != null) {
const dnsu2t = dnsToDnsu2t(dns);
if (dnsu2t) {
blocks.push(
RedsocksConf.stringifyBlock('dnsu2t', {
...dnsu2t,
local_ip: '127.0.0.1',
local_port: 53,
}),
);
}
}
return blocks.join('\n');
}
public static parse(rawConf: string): RedsocksConfig {
@ -59,6 +89,20 @@ export class RedsocksConf {
return conf;
}
// Extract contents of `dnsu2t {...}` using regex if exists
let dns: DnsConfig | null = null;
const rawDnsu2tBlockMatch = rawConf.match(blockRegexFor('dnsu2t'));
if (rawDnsu2tBlockMatch) {
const rawDnsu2tBlock = RedsocksConf.parseBlock(
rawDnsu2tBlockMatch[1],
disallowedProxyFields,
);
const maybeDnsConfig = DnsConfig.decode(rawDnsu2tBlock);
if (isRight(maybeDnsConfig)) {
dns = maybeDnsConfig.right;
}
}
// Extract contents of `redsocks {...}` using regex
const rawRedsocksBlockMatch = rawConf.match(blockRegexFor('redsocks'));
// No group was captured, indicating malformed config
@ -74,6 +118,8 @@ export class RedsocksConf {
if (isRight(maybeProxyConfig)) {
conf.redsocks = {
...maybeProxyConfig.right,
// Only add dns subfield if redsocks config is valid
...(dns && { dns: `${dns.remote_ip}:${dns.remote_port}` }),
};
return conf;
} else {
@ -138,6 +184,25 @@ export class RedsocksConf {
}
}
function dnsToDnsu2t(
dns: DnsInput,
): { remote_ip: string; remote_port: number } | null {
const dnsu2t = {
remote_ip: DEFAULT_REMOTE_IP,
remote_port: DEFAULT_REMOTE_PORT,
};
if (typeof dns === 'boolean') {
return dns ? dnsu2t : null;
} else {
// Convert dns string to config object
const [ip, port] = dns.split(':');
dnsu2t.remote_ip = ip;
dnsu2t.remote_port = parseInt(port, 10);
return dnsu2t;
}
}
export async function readProxy(): Promise<HostProxyConfig | undefined> {
// Get and parse redsocks.conf
let rawConf: string | undefined;

View File

@ -1,5 +1,15 @@
import * as t from 'io-ts';
import { NumericIdentifier } from '../types';
import { NumericIdentifier, shortStringWithRegex } from '../types';
const AddressString = shortStringWithRegex(
'AddressString',
/^.+:[0-9]+$/,
"must be a string in the format 'ADDRESS:PORT'",
);
type AddressString = t.TypeOf<typeof AddressString>;
export const DnsInput = t.union([AddressString, t.boolean]);
export type DnsInput = t.TypeOf<typeof DnsInput>;
export const ProxyConfig = t.intersection([
t.type({
@ -12,14 +22,21 @@ export const ProxyConfig = t.intersection([
ip: t.string,
port: NumericIdentifier,
}),
// login & password are optional fields
// login, password, and dns are optional fields
t.partial({
login: t.string,
password: t.string,
dns: DnsInput,
}),
]);
export type ProxyConfig = t.TypeOf<typeof ProxyConfig>;
export const DnsConfig = t.type({
remote_ip: t.string,
remote_port: NumericIdentifier,
});
export type DnsConfig = t.TypeOf<typeof DnsConfig>;
/**
* The internal object representation of redsocks.conf, obtained
* from RedsocksConf.parse
@ -47,10 +64,12 @@ export type HostProxyConfig = t.TypeOf<typeof HostProxyConfig>;
* with host-config PATCH and provided to the user with host-config GET.
*/
export const HostConfiguration = t.type({
network: t.partial({
proxy: HostProxyConfig,
hostname: t.string,
}),
network: t.exact(
t.partial({
proxy: t.exact(HostProxyConfig),
hostname: t.string,
}),
),
});
export type HostConfiguration = t.TypeOf<typeof HostConfiguration>;
@ -61,9 +80,18 @@ export type HostConfiguration = t.TypeOf<typeof HostConfiguration>;
* valid but has the correct shape.
*/
export const LegacyHostConfiguration = t.type({
network: t.partial({
proxy: t.record(t.string, t.any),
hostname: t.string,
}),
network: t.exact(
t.partial({
proxy: t.intersection([
t.record(t.string, t.any),
// Dns was added after the initial API endpoint was introduced,
// so we can be more strict with its type.
t.partial({
dns: DnsInput,
}),
]),
hostname: t.string,
}),
),
});
export type LegacyHostConfiguration = t.TypeOf<typeof LegacyHostConfiguration>;

View File

@ -93,7 +93,11 @@ const VAR_NAME_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
*/
const CONFIG_VAR_NAME_REGEX = /^[a-zA-Z_][a-zA-Z0-9_:]*$/;
const shortStringWithRegex = (name: string, regex: RegExp, message: string) =>
export const shortStringWithRegex = (
name: string,
regex: RegExp,
message: string,
) =>
new t.Type<string, string>(
name,
(s: unknown): s is string => ShortString.is(s) && regex.test(s),

View File

@ -15,3 +15,10 @@ redsocks {
local_ip = 127.0.0.1;
local_port = 12345;
}
dnsu2t {
remote_ip = 1.2.3.4;
remote_port = 54;
local_ip = 127.0.0.1;
local_port = 53;
}

View File

@ -31,6 +31,18 @@ describe('host-config', () => {
const noProxy = path.join(proxyBase, 'no_proxy');
const hostname = pathOnRoot('/etc/hostname');
const appLockDir = pathOnRoot(updateLock.lockPath(APP_ID));
const defaultConf = {
proxy: {
ip: 'example.org',
port: 1080,
type: 'socks5',
login: 'foo',
password: 'bar',
dns: '1.2.3.4:54',
noProxy: ['152.10.30.4', '253.1.1.0/16'],
},
hostname: 'deadbeef',
};
before(async () => {
await config.initialized();
@ -56,7 +68,7 @@ describe('host-config', () => {
'test/data/mnt/boot/system-proxy/redsocks.conf',
),
[noProxy]: testfs.from('test/data/mnt/boot/system-proxy/no_proxy'),
[hostname]: 'deadbeef',
[hostname]: defaultConf.hostname,
// Create a lock. This won't prevent host config patch unless
// there are current apps present, in which case an updates locked
// error will be thrown.
@ -70,6 +82,7 @@ describe('host-config', () => {
beforeEach(async () => {
await tFs.enable();
await config.set({ hostname: defaultConf.hostname });
// Stub external dependencies
stub(dbus, 'servicePartOf').resolves([]);
stub(dbus, 'restartService').resolves();
@ -85,17 +98,7 @@ describe('host-config', () => {
it('reads proxy configs and hostname', async () => {
const { network } = await get();
expect(network).to.have.property('hostname', 'deadbeef');
expect(network).to.have.property('proxy');
expect(network.proxy).to.have.property('ip', 'example.org');
expect(network.proxy).to.have.property('port', 1080);
expect(network.proxy).to.have.property('type', 'socks5');
expect(network.proxy).to.have.property('login', 'foo');
expect(network.proxy).to.have.property('password', 'bar');
expect(network.proxy).to.have.deep.property('noProxy', [
'152.10.30.4',
'253.1.1.0/16',
]);
expect(network).to.deep.equal(defaultConf);
});
it('prevents patch if update locks are present', async () => {
@ -117,7 +120,13 @@ describe('host-config', () => {
} catch (e: unknown) {
expect.fail(`Expected hostConfig.patch to not throw, but got ${e}`);
}
expect(await get()).to.deep.equal({ network: { hostname: 'deadbeef' } });
// Proxy should have been deleted as proxy was patched to empty,
// hostname should remain unchanged
const { network } = await get();
expect(network).to.deep.equal({
hostname: defaultConf.hostname,
});
expect(await config.get('hostname')).to.equal(defaultConf.hostname);
});
it('patches hostname regardless of update locks', async () => {
@ -125,44 +134,43 @@ describe('host-config', () => {
try {
await patch({ network: { hostname: 'test' } });
// /etc/hostname isn't changed until the balena-hostname service
// is restarted by the OS.
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 () => {
it('patches hostname without modifying other fields', async () => {
await patch({ network: { hostname: 'test' } });
// /etc/hostname isn't changed until the balena-hostname service
// is restarted by the OS.
expect(await config.get('hostname')).to.equal('test');
// Proxy should remain unchanged as patch didn't include it
const { network } = await get();
expect(network.proxy).to.deep.equal(defaultConf.proxy);
});
it('patches proxy', async () => {
const newConf = {
network: {
proxy: {
ip: 'example2.org',
port: 1090,
type: 'http-relay',
login: 'bar',
password: 'foo',
noProxy: ['balena.io', '222.22.2.2'],
},
},
it('patches proxy without modifying other fields', async () => {
const newProxy = {
ip: 'example2.org',
port: 1090,
type: 'http-relay',
login: 'bar',
password: 'foo',
dns: '2.2.2.2:52',
noProxy: ['balena.io', '222.22.2.2'],
};
await patch(newConf);
await patch({ network: { proxy: newProxy } });
const { network } = await get();
expect(network).to.have.property('proxy');
expect(network.proxy).to.have.property('ip', 'example2.org');
expect(network.proxy).to.have.property('port', 1090);
expect(network.proxy).to.have.property('type', 'http-relay');
expect(network.proxy).to.have.property('login', 'bar');
expect(network.proxy).to.have.property('password', 'foo');
expect(network.proxy).to.have.deep.property('noProxy', [
'balena.io',
'222.22.2.2',
]);
expect(network).to.deep.equal({
proxy: {
...defaultConf.proxy,
...newProxy,
},
hostname: defaultConf.hostname,
});
await expect(fs.readFile(redsocksConf, 'utf-8')).to.eventually.equal(
stripIndent`
@ -182,6 +190,13 @@ describe('host-config', () => {
password = "foo";
local_ip = 127.0.0.1;
local_port = 12345;
}
dnsu2t {
remote_ip = 2.2.2.2;
remote_port = 52;
local_ip = 127.0.0.1;
local_port = 53;
}` + '\n',
);
@ -192,25 +207,23 @@ describe('host-config', () => {
});
it('patches proxy fields specified while leaving unspecified fields unchanged', async () => {
const newProxyFields = {
ip: 'example2.org',
port: 1090,
};
await patch({
network: {
proxy: {
ip: 'example2.org',
port: 1090,
},
proxy: newProxyFields,
},
});
const { network } = await get();
expect(network).to.have.property('proxy');
expect(network.proxy).to.have.property('ip', 'example2.org');
expect(network.proxy).to.have.property('port', 1090);
expect(network.proxy).to.have.property('type', 'socks5');
expect(network.proxy).to.have.property('login', 'foo');
expect(network.proxy).to.have.property('password', 'bar');
expect(network.proxy).to.have.deep.property('noProxy', [
'152.10.30.4',
'253.1.1.0/16',
]);
expect(network).to.deep.equal({
proxy: {
...defaultConf.proxy,
...newProxyFields,
},
hostname: defaultConf.hostname,
});
await expect(fs.readFile(redsocksConf, 'utf-8')).to.eventually.equal(
stripIndent`
@ -230,6 +243,13 @@ describe('host-config', () => {
password = "bar";
local_ip = 127.0.0.1;
local_port = 12345;
}
dnsu2t {
remote_ip = 1.2.3.4;
remote_port = 54;
local_ip = 127.0.0.1;
local_port = 53;
}` + '\n',
);
@ -244,22 +264,15 @@ describe('host-config', () => {
it('patches proxy to empty if input is empty', async () => {
await patch({ network: { proxy: {} } });
const { network } = await get();
expect(network).to.not.have.property('proxy');
expect(network).to.deep.equal({
hostname: defaultConf.hostname,
});
});
it('keeps current proxy if input is invalid', async () => {
await patch({ network: { proxy: null as any } });
const { network } = await get();
expect(network).to.have.property('proxy');
expect(network.proxy).to.have.property('ip', 'example.org');
expect(network.proxy).to.have.property('port', 1080);
expect(network.proxy).to.have.property('type', 'socks5');
expect(network.proxy).to.have.property('login', 'foo');
expect(network.proxy).to.have.property('password', 'bar');
expect(network.proxy).to.have.deep.property('noProxy', [
'152.10.30.4',
'253.1.1.0/16',
]);
expect(network).to.deep.equal(defaultConf);
});
it('ignores unsupported fields when patching proxy', async () => {
@ -352,43 +365,41 @@ describe('host-config', () => {
it('skips restarting proxy services when part of redsocks-conf.target', async () => {
(dbus.servicePartOf as SinonStub).resolves(['redsocks-conf.target']);
const newProxy = {
ip: 'example2.org',
port: 1090,
type: 'http-relay',
login: 'bar',
password: 'foo',
dns: '4.3.2.1:52',
noProxy: ['balena.io', '222.22.2.2'],
};
await patch({
network: {
proxy: {
ip: 'example2.org',
port: 1090,
type: 'http-relay',
login: 'bar',
password: 'foo',
noProxy: ['balena.io', '222.22.2.2'],
},
proxy: newProxy,
},
});
expect(dbus.restartService as SinonStub).to.not.have.been.called;
const { network } = await get();
expect(network).to.have.property('proxy');
expect(network.proxy).to.have.property('ip', 'example2.org');
expect(network.proxy).to.have.property('port', 1090);
expect(network.proxy).to.have.property('type', 'http-relay');
expect(network.proxy).to.have.property('login', 'bar');
expect(network.proxy).to.have.property('password', 'foo');
expect(network.proxy).to.have.deep.property('noProxy', [
'balena.io',
'222.22.2.2',
]);
expect(network.proxy).to.deep.equal({
...defaultConf.proxy,
...newProxy,
});
});
it('patches redsocks.conf to be empty if prompted', async () => {
it('patches redsocks.conf to be empty', async () => {
await patch({ network: { proxy: {} } });
const { network } = await get();
expect(network).to.not.have.property('proxy');
expect(network).to.deep.equal({
hostname: defaultConf.hostname,
});
expect(await fs.readdir(proxyBase)).to.not.have.members([
'redsocks.conf',
'no_proxy',
]);
});
it('patches no_proxy to be empty if prompted', async () => {
it('patches no_proxy to be empty', async () => {
await patch({
network: {
proxy: {
@ -398,13 +409,41 @@ describe('host-config', () => {
});
const { network } = await get();
// If only noProxy is patched, redsocks.conf should remain unchanged
expect(network).to.have.property('proxy').that.deep.includes({
expect(network).to.have.property('proxy').that.deep.equals({
ip: 'example.org',
port: 1080,
type: 'socks5',
login: 'foo',
password: 'bar',
dns: '1.2.3.4:54',
});
await expect(fs.readFile(redsocksConf, 'utf-8')).to.eventually.equal(
stripIndent`
base {
log_debug = off;
log_info = on;
log = stderr;
daemon = off;
redirector = iptables;
}
redsocks {
ip = example.org;
port = 1080;
type = socks5;
login = "foo";
password = "bar";
local_ip = 127.0.0.1;
local_port = 12345;
}
dnsu2t {
remote_ip = 1.2.3.4;
remote_port = 54;
local_ip = 127.0.0.1;
local_port = 53;
}` + '\n',
);
expect(network.proxy).to.not.have.property('noProxy');
expect(await fs.readdir(proxyBase)).to.not.have.members(['no_proxy']);
});
@ -415,5 +454,217 @@ describe('host-config', () => {
const { network: newNetwork } = await get();
expect(network.hostname).to.equal(newNetwork.hostname);
expect(network.proxy).to.deep.equal(newNetwork.proxy);
await expect(fs.readFile(redsocksConf, 'utf-8')).to.eventually.equal(
stripIndent`
base {
log_debug = off;
log_info = on;
log = stderr;
daemon = off;
redirector = iptables;
}
redsocks {
ip = example.org;
port = 1080;
type = socks5;
login = "foo";
password = "bar";
local_ip = 127.0.0.1;
local_port = 12345;
}
dnsu2t {
remote_ip = 1.2.3.4;
remote_port = 54;
local_ip = 127.0.0.1;
local_port = 53;
}` + '\n',
);
});
it('patches dnsu2t config to default without modifying other fields', async () => {
await patch({
network: {
proxy: {
dns: true,
},
},
});
const { network } = await get();
expect(network.proxy).to.deep.equal({
...defaultConf.proxy,
dns: '8.8.8.8:53',
});
expect(await config.get('hostname')).to.equal(defaultConf.hostname);
await expect(fs.readFile(redsocksConf, 'utf-8')).to.eventually.equal(
stripIndent`
base {
log_debug = off;
log_info = on;
log = stderr;
daemon = off;
redirector = iptables;
}
redsocks {
ip = example.org;
port = 1080;
type = socks5;
login = "foo";
password = "bar";
local_ip = 127.0.0.1;
local_port = 12345;
}
dnsu2t {
remote_ip = 8.8.8.8;
remote_port = 53;
local_ip = 127.0.0.1;
local_port = 53;
}` + '\n',
);
});
it('patches dnsu2t config to string value', async () => {
await patch({
network: {
proxy: {
dns: '4.3.2.1:51',
},
},
});
const { network } = await get();
expect(network.proxy).to.deep.equal({
...defaultConf.proxy,
dns: '4.3.2.1:51',
});
await expect(fs.readFile(redsocksConf, 'utf-8')).to.eventually.equal(
stripIndent`
base {
log_debug = off;
log_info = on;
log = stderr;
daemon = off;
redirector = iptables;
}
redsocks {
ip = example.org;
port = 1080;
type = socks5;
login = "foo";
password = "bar";
local_ip = 127.0.0.1;
local_port = 12345;
}
dnsu2t {
remote_ip = 4.3.2.1;
remote_port = 51;
local_ip = 127.0.0.1;
local_port = 53;
}` + '\n',
);
});
it('patches dnsu2t config to empty', async () => {
await patch({
network: {
proxy: {
dns: false,
},
},
});
const { network } = await get();
const { dns, ...proxyWithoutDns } = defaultConf.proxy;
expect(network.proxy).to.deep.equal(proxyWithoutDns);
});
it('adds dnsu2t config to config without dnsu2t when provided valid input', async () => {
// Delete dns config to set up test
await patch({
network: {
proxy: {
dns: false,
},
},
});
const { network } = await get();
expect(network.proxy).to.not.have.property('dns');
expect(await fs.readFile(redsocksConf, 'utf-8')).to.not.contain('dnsu2t');
// Add valid dns config
await patch({
network: {
proxy: {
dns: '5.5.5.5:55',
},
},
});
const { network: n2 } = await get();
expect(n2.proxy).to.deep.equal({
...defaultConf.proxy,
dns: '5.5.5.5:55',
});
await expect(fs.readFile(redsocksConf, 'utf-8')).to.eventually.equal(
stripIndent`
base {
log_debug = off;
log_info = on;
log = stderr;
daemon = off;
redirector = iptables;
}
redsocks {
ip = example.org;
port = 1080;
type = socks5;
login = "foo";
password = "bar";
local_ip = 127.0.0.1;
local_port = 12345;
}
dnsu2t {
remote_ip = 5.5.5.5;
remote_port = 55;
local_ip = 127.0.0.1;
local_port = 53;
}` + '\n',
);
});
it("does not add dnsu2t config when redsocks proxy isn't configured", async () => {
// Delete redsocks config to set up test
await patch({
network: {
proxy: {},
},
});
const { network } = await get();
expect(network).to.not.have.property('proxy');
expect(await fs.readdir(proxyBase)).to.not.have.members([
'redsocks.conf',
'no_proxy',
]);
// Add valid dns config
await patch({
network: {
proxy: {
dns: '1.2.3.4:54',
},
},
});
const { network: n2 } = await get();
expect(n2).to.deep.equal({
hostname: defaultConf.hostname,
});
expect(await fs.readdir(proxyBase)).to.not.have.members([
'redsocks.conf',
'no_proxy',
]);
});
});

View File

@ -4,7 +4,12 @@ import type { SinonStub } from 'sinon';
import * as hostConfig from '~/src/host-config';
import { RedsocksConf } from '~/src/host-config/proxy';
import type { RedsocksConfig, ProxyConfig } from '~/src/host-config/types';
import {
type RedsocksConfig,
type ProxyConfig,
LegacyHostConfiguration,
DnsInput,
} from '~/src/host-config/types';
import log from '~/lib/supervisor-console';
describe('RedsocksConf', () => {
@ -124,34 +129,163 @@ describe('RedsocksConf', () => {
const confStr = RedsocksConf.stringify(conf);
expect(confStr).to.equal('');
});
it('stringifies dns to separate dnsu2t block', () => {
const conf: RedsocksConfig = {
redsocks: {
type: 'socks5',
ip: 'example.org',
port: 1080,
login: '"foo"',
password: '"bar"',
dns: '1.1.1.1:54',
},
};
const confStr = RedsocksConf.stringify(conf);
expect(confStr).to.equal(
stripIndent`
base {
log_debug = off;
log_info = on;
log = stderr;
daemon = off;
redirector = iptables;
}
redsocks {
type = socks5;
ip = example.org;
port = 1080;
login = "foo";
password = "bar";
local_ip = 127.0.0.1;
local_port = 12345;
}
dnsu2t {
remote_ip = 1.1.1.1;
remote_port = 54;
local_ip = 127.0.0.1;
local_port = 53;
}
` + '\n',
);
});
it('stringifies dns: true to default dnsu2t config', () => {
const conf: RedsocksConfig = {
redsocks: {
type: 'socks5',
ip: 'example.org',
port: 1080,
login: '"foo"',
password: '"bar"',
dns: true,
},
};
const confStr = RedsocksConf.stringify(conf);
expect(confStr).to.equal(
stripIndent`
base {
log_debug = off;
log_info = on;
log = stderr;
daemon = off;
redirector = iptables;
}
redsocks {
type = socks5;
ip = example.org;
port = 1080;
login = "foo";
password = "bar";
local_ip = 127.0.0.1;
local_port = 12345;
}
dnsu2t {
remote_ip = 8.8.8.8;
remote_port = 53;
local_ip = 127.0.0.1;
local_port = 53;
}
` + '\n',
);
});
it('does not include dnsu2t config if dns: false', () => {
const conf: RedsocksConfig = {
redsocks: {
type: 'socks5',
ip: 'example.org',
port: 1080,
login: '"foo"',
password: '"bar"',
dns: false,
},
};
const confStr = RedsocksConf.stringify(conf);
expect(confStr).to.equal(
stripIndent`
base {
log_debug = off;
log_info = on;
log = stderr;
daemon = off;
redirector = iptables;
}
redsocks {
type = socks5;
ip = example.org;
port = 1080;
login = "foo";
password = "bar";
local_ip = 127.0.0.1;
local_port = 12345;
}
` + '\n',
);
});
it('does not stringify dnsu2t if no other fields in redsocks config', () => {
const conf = {
redsocks: {
dns: '3.3.3.3:52',
},
} as RedsocksConfig;
const confStr = RedsocksConf.stringify(conf);
expect(confStr).to.equal('');
});
});
describe('parse', () => {
it('parses config string into RedsocksConfig', () => {
const redsocksConfStr = stripIndent`
base {
log_debug = off;
log_info = on;
log = stderr;
daemon = off;
redirector = iptables;
}
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";
}
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 = RedsocksConf.parse(redsocksConfStr);
expect(conf).to.deep.equal({
redsocks: {
@ -166,26 +300,26 @@ describe('RedsocksConf', () => {
it("parses `redsocks {...}` config block no matter what position it's in or how many newlines surround it", () => {
const redsocksConfStr = stripIndent`
dnsu2t {
dnstc {
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";
}
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;
log_info = on;
log = stderr;
daemon = off;
redirector = iptables;
}
`;
`;
const conf = RedsocksConf.parse(redsocksConfStr);
expect(conf).to.deep.equal({
redsocks: {
@ -276,17 +410,17 @@ describe('RedsocksConf', () => {
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;
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 = RedsocksConf.parse(redsocksConfStr);
expect((log.warn as SinonStub).lastCall.args[0]).to.equal(
@ -306,24 +440,24 @@ describe('RedsocksConf', () => {
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;
}
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";
}
`;
redsocks {
local_ip 127.0.0.1;
local_port = 12345
= socks5;
ip = ;
= 1080;
login =;
password = "bar";
}
`;
(log.warn as SinonStub).resetHistory();
const conf = RedsocksConf.parse(redsocksConfStr);
expect(
@ -350,12 +484,12 @@ describe('RedsocksConf', () => {
it('parses to empty config with warnings when a block is empty', () => {
const redsocksConfStr = stripIndent`
base {
}
base {
}
redsocks {
}
`;
redsocks {
}
`;
(log.warn as SinonStub).resetHistory();
const conf = RedsocksConf.parse(redsocksConfStr);
expect(
@ -375,6 +509,199 @@ describe('RedsocksConf', () => {
(log.warn as SinonStub).resetHistory();
expect(conf).to.deep.equal({});
});
it('parses dnsu2t to dns field in redsocks config', () => {
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";
}
dnsu2t {
remote_ip = 1.1.1.1;
remote_port = 54;
local_ip = 127.0.0.1;
local_port = 53;
}
`;
const conf = RedsocksConf.parse(redsocksConfStr);
expect(conf).to.deep.equal({
redsocks: {
type: 'socks5',
ip: 'example.org',
port: 1080,
login: 'foo',
password: 'bar',
dns: '1.1.1.1:54',
},
});
});
it('parses dnsu2t no matter its position', () => {
const redsocksConfStr = stripIndent`
base {
log_debug = off;
log_info = on;
log = stderr;
daemon = off;
redirector = iptables;
}
dnsu2t {
remote_ip = 1.1.1.1;
remote_port = 54;
local_ip = 127.0.0.1;
local_port = 53;
}
redsocks {
local_ip = 127.0.0.1;
local_port = 12345;
type = socks5;
ip = example.org;
port = 1080;
login = "foo";
password = "bar";
}
`;
const conf = RedsocksConf.parse(redsocksConfStr);
expect(conf).to.deep.equal({
redsocks: {
type: 'socks5',
ip: 'example.org',
port: 1080,
login: 'foo',
password: 'bar',
dns: '1.1.1.1:54',
},
});
});
it('does not parse dnsu2t to dns field if dnsu2t is partial or invalid', () => {
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";
}
dnsu2t {
test = true;
}
`;
const conf = RedsocksConf.parse(redsocksConfStr);
expect(conf).to.deep.equal({
redsocks: {
type: 'socks5',
ip: 'example.org',
port: 1080,
login: 'foo',
password: 'bar',
},
});
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 = socks5;
ip = example.org;
port = 1080;
login = "foo";
password = "bar";
}
dnsu2t {
remote_port = 53;
}
`;
const conf2 = RedsocksConf.parse(redsocksConfStr2);
expect(conf2).to.deep.equal({
redsocks: {
type: 'socks5',
ip: 'example.org',
port: 1080,
login: 'foo',
password: 'bar',
},
});
});
it('does not parse dnsu2t to dns field if missing or invalid redsocks config', () => {
const redsocksConfStr = stripIndent`
base {
log_debug = off;
log_info = on;
log = stderr;
daemon = off;
redirector = iptables;
}
dnsu2t {
remote_ip = 1.1.1.1;
remote_port = 54;
local_ip = 127.0.0.1;
local_port = 53;
}
`;
const conf = RedsocksConf.parse(redsocksConfStr);
expect(conf).to.deep.equal({});
const redsocksConfStr2 = stripIndent`
base {
log_debug = off;
log_info = on;
log = stderr;
daemon = off;
redirector = iptables;
}
redsocks {
type = socks5;
}
dnsu2t {
remote_ip = 1.1.1.1;
remote_port = 54;
local_ip = 127.0.0.1;
local_port = 53;
}
`;
const conf2 = RedsocksConf.parse(redsocksConfStr2);
expect(conf2).to.deep.equal({});
});
});
});
@ -394,19 +721,7 @@ describe('src/host-config', () => {
hostname: 'balena',
},
};
expect(hostConfig.parse(conf)).to.deep.equal({
network: {
proxy: {
type: 'socks4',
ip: 'balena.io',
port: 1079,
login: '"baz"',
password: '"foo"',
noProxy: ['8.8.8.8'],
},
hostname: 'balena',
},
});
expect(hostConfig.parse(conf)).to.deep.equal(conf);
});
it('parses valid HostConfiguration with only hostname', () => {
@ -415,11 +730,7 @@ describe('src/host-config', () => {
hostname: 'balena2',
},
};
expect(hostConfig.parse(conf)).to.deep.equal({
network: {
hostname: 'balena2',
},
});
expect(hostConfig.parse(conf)).to.deep.equal(conf);
});
it('parses valid HostConfiguration with only proxy', () => {
@ -435,18 +746,7 @@ describe('src/host-config', () => {
},
},
};
expect(hostConfig.parse(conf)).to.deep.equal({
network: {
proxy: {
type: 'http-connect',
ip: 'test.balena.io',
port: 1081,
login: '"foo"',
password: '"bar"',
noProxy: ['3.3.3.3'],
},
},
});
expect(hostConfig.parse(conf)).to.deep.equal(conf);
});
it('parses valid HostConfiguration with only noProxy', () => {
@ -457,13 +757,7 @@ describe('src/host-config', () => {
},
},
};
expect(hostConfig.parse(conf)).to.deep.equal({
network: {
proxy: {
noProxy: ['1.1.1.1', '2.2.2.2'],
},
},
});
expect(hostConfig.parse(conf)).to.deep.equal(conf);
});
it('parses HostConfiguration where auth fields are missing double quotes', () => {
@ -480,18 +774,7 @@ describe('src/host-config', () => {
},
};
(log.warn as SinonStub).resetHistory();
expect(hostConfig.parse(conf)).to.deep.equal({
network: {
proxy: {
type: 'http-connect',
ip: 'test.balena.io',
port: 1081,
login: 'foo',
password: 'bar',
noProxy: ['3.3.3.3'],
},
},
});
expect(hostConfig.parse(conf)).to.deep.equal(conf);
// Should not warn about missing double quotes
expect(log.warn as SinonStub).to.not.have.been.called;
});
@ -541,18 +824,7 @@ describe('src/host-config', () => {
},
};
(log.warn as SinonStub).resetHistory();
expect(hostConfig.parse(conf)).to.deep.equal({
network: {
proxy: {
type: 'socks6',
ip: 123,
port: 'abc',
login: 'user',
password: 'pass',
noProxy: true,
},
},
});
expect(hostConfig.parse(conf)).to.deep.equal(conf);
expect((log.warn as SinonStub).lastCall.args[0]).to.equal(
'Malformed host config detected, things may not behave as expected:\n' +
'Expecting string at network.proxy.0.0.ip but instead got: 123\n' +
@ -570,7 +842,11 @@ describe('src/host-config', () => {
it('throws error for HostConfiguration without network key', () => {
expect(() => hostConfig.parse({})).to.throw(
'Could not parse host config input to a valid format',
'Could not parse host config input to a valid format:\n' +
'Expecting Partial<{| ' +
'proxy: ({ [K in string]: any } & Partial<{ dns: (AddressString | boolean) }>), ' +
'hostname: string ' +
'|}> at network but instead got: undefined',
);
});
@ -579,7 +855,11 @@ describe('src/host-config', () => {
network: 123,
};
expect(() => hostConfig.parse(conf)).to.throw(
'Could not parse host config input to a valid format',
'Could not parse host config input to a valid format:\n' +
'Expecting Partial<{| ' +
'proxy: ({ [K in string]: any } & Partial<{ dns: (AddressString | boolean) }>), ' +
'hostname: string ' +
'|}> at network but instead got: 123',
);
});
@ -590,8 +870,200 @@ describe('src/host-config', () => {
},
};
expect(() => hostConfig.parse(conf)).to.throw(
'Could not parse host config input to a valid format',
'Could not parse host config input to a valid format:\n' +
'Expecting string at network.hostname but instead got: 123',
);
});
it('throws error for HostConfiguration with invalid dns', () => {
const invalids = [
123, // wrong type
'invalid-because-no-colon',
':', // only colon
':53', // missing address
'1.1.1.1', // missing port
'example.com:not-a-port', // wrong port type
];
for (const dns of invalids) {
const conf = {
network: {
proxy: {
type: 'http-connect',
ip: 'test.balena.io',
port: 1081,
login: '"foo"',
password: '"bar"',
dns,
},
},
};
const formattedDns = typeof dns === 'string' ? `"${dns}"` : dns;
expect(() => hostConfig.parse(conf)).to.throw(
'Could not parse host config input to a valid format:\n' +
'Expecting one of:\n' +
' AddressString\n' +
' boolean\n' +
`at network.proxy.1.dns but instead got: ${formattedDns}`,
);
}
});
it('parses valid dns input', () => {
const valids = ['balena.io:53', '1.1.1.1:5', 'balena.io:65535'];
for (const dns of valids) {
const conf = {
network: {
proxy: {
type: 'http-connect',
ip: 'test.balena.io',
port: 1081,
login: '"foo"',
password: '"bar"',
dns,
},
},
};
expect(hostConfig.parse(conf)).to.deep.equal(conf);
}
});
it("strips additional inputs from HostConfiguration while not erroring if some optional inputs aren't present", () => {
const conf = {
network: {
proxy: {
type: 'http-connect',
ip: 'test.balena.io',
port: 1081,
// login optional field present
// but password optional field missing
login: '"foo"',
noProxy: ['2.2.2.2'],
// local_* invalid fields present
local_ip: '127.0.0.2',
local_port: 1082,
// extra key present
extra1: 123,
},
// extra key present
extra2: true,
},
};
expect(hostConfig.parse(conf)).to.deep.equal({
network: {
proxy: {
type: 'http-connect',
ip: 'test.balena.io',
port: 1081,
login: '"foo"',
noProxy: ['2.2.2.2'],
},
},
});
});
});
describe('types', () => {
describe('DnsInput', () => {
it('decodes to DnsInput boolean', () => {
expect(DnsInput.is(true)).to.be.true;
expect(DnsInput.is(false)).to.be.true;
});
it('decodes to DnsInput from string in the format "ADDRESS:PORT"', () => {
expect(DnsInput.is('1.2.3.4:53')).to.be.true;
expect(DnsInput.is('example.com:53')).to.be.true;
});
it('does not decode to DnsInput from invalid string', () => {
expect(DnsInput.is('')).to.be.false;
expect(DnsInput.is(':')).to.be.false;
expect(DnsInput.is('1.2.3.4:')).to.be.false;
expect(DnsInput.is(':53')).to.be.false;
expect(DnsInput.is('example.com:')).to.be.false;
expect(DnsInput.is('1.2.3.4')).to.be.false;
expect(DnsInput.is('example.com')).to.be.false;
});
});
describe('LegacyHostConfiguration', () => {
it('maintains strict dns typing for LegacyHostConfiguration', () => {
expect(
LegacyHostConfiguration.is({
network: {
proxy: {
legacy: 'field',
dns: 123,
},
},
}),
).to.be.false;
expect(
LegacyHostConfiguration.is({
network: {
proxy: {
legacy: 'field',
dns: '1.1.1.1:53',
},
},
}),
).to.be.true;
expect(
LegacyHostConfiguration.is({
network: {
proxy: {
legacy: 'field',
dns: true,
},
},
}),
).to.be.true;
expect(
LegacyHostConfiguration.is({
network: {
proxy: {
legacy: 'field',
dns: false,
},
},
}),
).to.be.true;
expect(
LegacyHostConfiguration.is({
network: {
proxy: {
legacy: 'field',
dns: '',
},
},
}),
).to.be.false;
expect(
LegacyHostConfiguration.is({
network: {
proxy: {
legacy: 'field',
dns: ':53',
},
},
}),
).to.be.false;
expect(
LegacyHostConfiguration.is({
network: {
proxy: {
legacy: 'field',
dns: '1.1.1.1',
},
},
}),
).to.be.false;
});
});
});
});