mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2025-01-29 07:34:21 +00:00
53f5641ef1
The host-config module exposes the following interfaces: get, patch, and parse. `get` gets host configuration such as redsocks proxy configuration and hostname and returns it in an object of type HostConfiguration. `patch` takes an object of type HostConfiguration or LegacyHostConfiguration and updates the hostname and redsocks proxy configuration, optionally forcing the patch through update locks. `parse` takes a user input of unknown type and parses it into type HostConfiguration or LegacyHostConfiguration for patching, erroring if parse was unsuccessful. LegacyHostConfiguration is a looser typing of the user input which does not validate values of the five known proxy fields of type, ip, port, username, and password. We should stop supporting it in the next major Supervisor API release. Change-type: minor Signed-off-by: Christina Ying Wang <christina@balena.io>
649 lines
16 KiB
TypeScript
649 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';
|
|
import { RedsocksConf } from '~/src/host-config';
|
|
import type { RedsocksConfig, ProxyConfig } from '~/src/host-config';
|
|
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('throws error for HostConfiguration without network key', () => {
|
|
expect(() => hostConfig.parse({})).to.throw(
|
|
'Could not parse host config input to a valid format',
|
|
);
|
|
});
|
|
|
|
it('throws error for HostConfiguration with invalid network key', () => {
|
|
const conf = {
|
|
network: 123,
|
|
};
|
|
expect(() => hostConfig.parse(conf)).to.throw(
|
|
'Could not parse host config input to a valid format',
|
|
);
|
|
});
|
|
|
|
it('throws error for HostConfiguration with invalid hostname', () => {
|
|
const conf = {
|
|
network: {
|
|
hostname: 123,
|
|
},
|
|
};
|
|
expect(() => hostConfig.parse(conf)).to.throw(
|
|
'Could not parse host config input to a valid format',
|
|
);
|
|
});
|
|
});
|
|
});
|