balena-supervisor/test/unit/host-config.spec.ts
Christina Ying Wang be986a62a5 Add HostConfig.parse method
Parses input from PATCH /v1/device/host-config into either
type HostConfiguration, or if LegacyHostConfiguration if
input is of an acceptable shape (for backwards compatibility).

Once input has been determined to be of type HostConfiguration,
we can easily extract ProxyConfig from the object for patching,
stringifying, and writing to redsocks.conf.

Change-type: minor
Signed-off-by: Christina Ying Wang <christina@balena.io>
2024-07-03 16:47:51 -07:00

643 lines
16 KiB
TypeScript

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 log from '~/lib/supervisor-console';
describe('RedsocksConf', () => {
describe('stringify', () => {
it('stringifies RedsocksConfig into config string', () => {
const conf: RedsocksConfig = {
redsocks: {
type: 'socks5',
ip: 'example.org',
port: 1080,
login: '"foo"',
password: '"bar"',
},
};
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('adds double quotes to auth fields if not exists', () => {
const conf: RedsocksConfig = {
redsocks: {
type: 'socks5',
ip: 'example.org',
port: 1080,
login: 'foo',
password: 'bar',
},
};
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('accepts port field of type string', () => {
const conf = {
redsocks: {
type: 'socks5',
ip: 'example.org',
port: '1080',
login: 'foo',
password: 'bar',
},
} as unknown as RedsocksConfig;
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('stringifies to empty string when provided empty RedsocksConfig', () => {
const conf: RedsocksConfig = {};
const confStr = RedsocksConf.stringify(conf);
expect(confStr).to.equal('');
});
it('stringifies to empty string when provided empty redsocks block', () => {
const conf: RedsocksConfig = {
redsocks: {} as ProxyConfig,
};
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;
}
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: {
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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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({});
});
});
});
describe('src/host-config', () => {
describe('patchProxy', () => {
it('patches RedsocksConfig with new values', () => {
const current = {
redsocks: {
type: 'socks5',
ip: 'example.org',
port: 1080,
login: '"foo"',
password: '"bar"',
},
} as RedsocksConfig;
const input = {
redsocks: {
type: 'http-connect',
ip: 'test.balena.io',
},
} as any;
const patched = hostConfig.patchProxy(current, input);
expect(patched).to.deep.equal({
redsocks: {
// Patched fields are updated
type: 'http-connect',
ip: 'test.balena.io',
// Unpatched fields retain their original values
port: 1080,
login: '"foo"',
password: '"bar"',
},
});
});
it('returns empty RedsocksConfig if redsocks config block is empty or invalid', () => {
const current: RedsocksConfig = {
redsocks: {
type: 'socks5',
ip: 'example.org',
port: 1080,
login: '"foo"',
password: '"bar"',
},
};
expect(hostConfig.patchProxy(current, { redsocks: {} })).to.deep.equal(
{},
);
expect(
hostConfig.patchProxy(current, { redsocks: true } as any),
).to.deep.equal({});
expect(hostConfig.patchProxy(current, {})).to.deep.equal({});
});
});
describe('parse', () => {
it('parses valid HostConfiguration', () => {
const conf = {
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({
network: {
proxy: {
type: 'socks4',
ip: 'balena.io',
port: 1079,
login: '"baz"',
password: '"foo"',
noProxy: ['8.8.8.8'],
},
hostname: 'balena',
},
});
});
it('parses valid HostConfiguration with only hostname', () => {
const conf = {
network: {
hostname: 'balena2',
},
};
expect(hostConfig.parse(conf)).to.deep.equal({
network: {
hostname: 'balena2',
},
});
});
it('parses valid HostConfiguration with only proxy', () => {
const conf = {
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({
network: {
proxy: {
type: 'http-connect',
ip: 'test.balena.io',
port: 1081,
login: '"foo"',
password: '"bar"',
noProxy: ['3.3.3.3'],
},
},
});
});
it('parses valid HostConfiguration with only noProxy', () => {
const conf = {
network: {
proxy: {
noProxy: ['1.1.1.1', '2.2.2.2'],
},
},
};
expect(hostConfig.parse(conf)).to.deep.equal({
network: {
proxy: {
noProxy: ['1.1.1.1', '2.2.2.2'],
},
},
});
});
it('parses HostConfiguration where auth fields are missing double quotes', () => {
const conf = {
network: {
proxy: {
type: 'http-connect',
ip: 'test.balena.io',
port: 1081,
login: 'foo',
password: 'bar',
noProxy: ['3.3.3.3'],
},
},
};
(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'],
},
},
});
// Should not warn about missing double quotes
expect(log.warn as SinonStub).to.not.have.been.called;
});
it('parses HostConfiguration where port is a string', () => {
const conf = {
network: {
proxy: {
type: 'http-connect',
ip: 'test.balena.io',
port: '1081',
login: '"foo"',
password: '"bar"',
noProxy: ['3.3.3.3'],
},
},
};
(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'],
},
},
});
// Should not warn about port being a string
expect(log.warn as SinonStub).to.not.have.been.called;
});
// Allow invalid fields through for backwards compatibility
it('parses input with invalid proxy as LegacyHostConfiguration with console warnings', () => {
const conf = {
network: {
proxy: {
type: 'socks6',
ip: 123,
port: 'abc',
login: 'user',
password: 'pass',
noProxy: true,
},
},
};
(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((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' +
'Expecting NumericIdentifier at network.proxy.0.0.port but instead got: "abc" (must be be an positive integer)\n' +
'Expecting one of:\n' +
' "socks4"\n' +
' "socks5"\n' +
' "http-connect"\n' +
' "http-relay"\n' +
'at network.proxy.0.0.type but instead got: "socks6"\n' +
'Expecting Array<string> at network.proxy.1.noProxy but instead got: true',
);
(log.warn as SinonStub).resetHistory();
});
it('parses to null for HostConfiguration without network key', () => {
expect(hostConfig.parse({})).to.be.null;
});
it('parses to null for HostConfiguration with invalid network key', () => {
const conf = {
network: 123,
};
expect(hostConfig.parse(conf)).to.be.null;
});
it('parses to null for HostConfiguration with invalid hostname', () => {
const conf = {
network: {
hostname: 123,
},
};
expect(hostConfig.parse(conf)).to.be.null;
});
});
});