firewall: Add Host Firewall functionality

Controlled by BALENA_HOST_FIREWALL_MODE, the firewall can
either be 'on' or 'off'.

- In the 'off' state, all traffic is allowed.
- In the 'on' state, only traffic for the core services provided
  by Balena is allowed.

Change-type: patch
Signed-off-by: Rich Bayliss <rich@balena.io>
This commit is contained in:
Rich Bayliss 2020-06-15 17:46:33 +01:00
parent fc70b1c6f8
commit 28c5a44e71
No known key found for this signature in database
GPG Key ID: E53C4B4D18499E1A
17 changed files with 930 additions and 210 deletions

View File

@ -49,9 +49,6 @@ export const once: typeof events['once'] = events.once.bind(events);
export const removeListener: typeof events['removeListener'] = events.removeListener.bind(
events,
);
export const removeAllListeners: typeof events['removeAllListeners'] = events.removeAllListeners.bind(
events,
);
export async function get<T extends SchemaTypeKey>(
key: T,

View File

@ -166,6 +166,10 @@ export const schemaTypes = {
type: PermissiveBoolean,
default: false,
},
firewallMode: {
type: t.string,
default: NullOrUndefined,
},
// Function schema types
// The type should be the value that the promise resolves

View File

@ -185,6 +185,11 @@ export const schema = {
mutable: true,
removeIfNull: false,
},
firewallMode: {
source: 'db',
mutable: true,
removeIfNull: false,
},
};
export type Schema = typeof schema;

View File

@ -122,6 +122,11 @@ export class DeviceConfig {
defaultValue: 'false',
rebootRequired: true,
},
firewallMode: {
envVarName: 'HOST_FIREWALL_MODE',
varType: 'string',
defaultValue: 'off',
},
};
public static validKeys = [
@ -580,6 +585,8 @@ export class DeviceConfig {
return checkTruthy(a) === checkTruthy(b);
case 'int':
return checkInt(a) === checkInt(b);
case 'string':
return a === b;
default:
throw new Error('Incorrect datatype passed to DeviceConfig.configTest');
}

192
src/lib/firewall.ts Normal file
View File

@ -0,0 +1,192 @@
import * as _ from 'lodash';
import * as config from '../config/index';
import * as constants from './constants';
import * as iptables from './iptables';
import { log } from './supervisor-console';
import * as dbFormat from '../device-state/db-format';
export const initialised = (async () => {
await config.initialized;
await applyFirewall();
// apply firewall whenever relevant config changes occur...
config.on('change', async ({ firewallMode, localMode }) => {
if (firewallMode || localMode != null) {
applyFirewall({ firewallMode, localMode });
}
});
})();
const BALENA_FIREWALL_CHAIN = 'BALENA-FIREWALL';
const LOG_PREFIX = '🔥';
const prepareChain: iptables.Rule[] = [
{
action: iptables.RuleAction.Flush,
},
];
const standardServices: iptables.Rule[] = [
{
comment: 'SSH Server',
action: iptables.RuleAction.Append,
proto: 'tcp',
matches: ['--dport 22222'],
target: 'ACCEPT',
},
{
comment: 'balenaEngine',
action: iptables.RuleAction.Append,
proto: 'tcp',
matches: ['--dport 2375'],
target: 'ACCEPT',
},
{
comment: 'mDNS',
action: iptables.RuleAction.Append,
matches: ['-m addrtype', '--dst-type MULTICAST'],
target: 'ACCEPT',
},
{
comment: 'ICMP',
action: iptables.RuleAction.Append,
proto: 'icmp',
target: 'ACCEPT',
},
];
const standardPolicy: iptables.Rule[] = [
{
comment: 'Locally-sourced traffic',
action: iptables.RuleAction.Insert,
matches: ['-m addrtype', '--src-type LOCAL'],
target: 'ACCEPT',
},
{
action: iptables.RuleAction.Insert,
matches: ['-m state', '--state ESTABLISHED,RELATED'],
target: 'ACCEPT',
},
{
comment: 'Reject everything else',
action: iptables.RuleAction.Append,
target: 'REJECT',
},
];
let supervisorAccessRules: iptables.Rule[] = [];
function updateSupervisorAccessRules(
localMode: boolean,
interfaces: string[],
port: number,
) {
supervisorAccessRules = [];
// if localMode then add a dummy interface placeholder, otherwise add each interface...
const matchesIntf = localMode
? [[]]
: interfaces.map((intf) => [`-i ${intf}`]);
matchesIntf.forEach((intf) =>
supervisorAccessRules.push({
comment: 'Supervisor API',
action: iptables.RuleAction.Append,
proto: 'tcp',
matches: [`--dport ${port}`, ...intf],
target: 'ACCEPT',
}),
);
}
async function runningHostBoundServices(): Promise<boolean> {
const apps = await dbFormat.getApps();
return _(apps).some((app) =>
_(app.services).some((svc) => svc.config.networkMode === 'host'),
);
}
async function applyFirewall(
opts?: Partial<{ firewallMode: string | null; localMode: boolean }>,
) {
// grab the current config...
const currentConfig = await config.getMany([
'listenPort',
'firewallMode',
'localMode',
]);
// populate missing config elements...
const { listenPort, firewallMode, localMode } = {
...opts,
...currentConfig,
};
// update the Supervisor API access rules...
updateSupervisorAccessRules(
localMode,
constants.allowedInterfaces,
listenPort,
);
// apply the firewall rules...
await exports.applyFirewallMode(firewallMode ?? '');
}
export const ALLOWED_MODES = ['on', 'off', 'auto'];
export async function applyFirewallMode(mode: string) {
// only apply valid mode...
if (!ALLOWED_MODES.includes(mode)) {
log.warn(`Invalid firewall mode: ${mode}. Reverting to state: off`);
mode = 'off';
}
log.info(`${LOG_PREFIX} Applying firewall mode: ${mode}`);
// get an adaptor to manipulate iptables rules...
const ruleAdaptor = iptables.getDefaultRuleAdaptor();
// are we running services in host-network mode?
const isServicesInHostNetworkMode = await runningHostBoundServices();
// should we allow only traffic to the balena host services?
const returnIfOff: iptables.Rule | iptables.Rule[] =
mode === 'off' || (mode === 'auto' && !isServicesInHostNetworkMode)
? {
comment: `Firewall disabled (${mode})`,
action: iptables.RuleAction.Insert,
target: 'RETURN',
}
: [];
// configure the BALENA-FIREWALL chain...
await iptables
.build()
.forTable('filter', (filter) =>
filter
.forChain(BALENA_FIREWALL_CHAIN, (chain) =>
chain
.addRule(prepareChain)
.addRule(supervisorAccessRules)
.addRule(standardServices)
.addRule(standardPolicy)
.addRule(returnIfOff),
)
.forChain('INPUT', (chain) =>
chain
.addRule({
action: iptables.RuleAction.Flush,
})
.addRule({
action: iptables.RuleAction.Append,
target: 'BALENA-FIREWALL',
}),
),
)
.apply(ruleAdaptor);
// all done!
log.success(`${LOG_PREFIX} Firewall mode applied`);
}

View File

@ -1,66 +1,286 @@
import * as Bluebird from 'bluebird';
import * as childProcess from 'child_process';
import * as _ from 'lodash';
import { child_process } from 'mz';
import { Readable } from 'stream';
// The following is exported so that we stub it in the tests
export const execAsync: (args: string) => Bluebird<string> = Bluebird.promisify(
childProcess.exec,
);
export class IPTablesRuleError extends Error {}
function applyIptablesArgs(args: string): Bluebird<void> {
let err: Error | null = null;
// We want to run both commands regardless, but also rethrow an error
// if one of them fails
return execAsync(`iptables ${args}`)
.catch((e) => (err = e))
.then(() => execAsync(`ip6tables ${args}`).catch((e) => (err = e)))
.then(() => {
if (err != null) {
throw err;
}
export enum RuleAction {
Insert = '-I',
Append = '-A',
Flush = '-F',
}
export interface Rule {
id?: number;
family?: 4 | 6;
action?: RuleAction;
target?: 'ACCEPT' | 'BLOCK' | 'REJECT' | string;
chain?: string;
table?: 'filter' | string;
proto?: 'all' | any;
src?: string;
dest?: string;
matches?: string[];
comment?: string;
}
export type RuleAdaptor = (rules: Rule[]) => Promise<void>;
export interface RuleBuilder {
addRule: (rules: Rule | Rule[]) => RuleBuilder;
}
export interface ChainBuilder {
forChain: (
chain: string,
context: (rules: RuleBuilder) => RuleBuilder,
) => ChainBuilder;
}
export interface TableBuilder {
forTable: (
table: string,
context: (chains: ChainBuilder) => ChainBuilder,
) => TableBuilder;
apply: (adaptor: RuleAdaptor) => Promise<void>;
}
/**
* Returns the default RuleAdaptor which is used to _applyRules_ later on.
*
* @export
* @returns {RuleAdaptor}
*/
export function getDefaultRuleAdaptor(): RuleAdaptor {
return iptablesRestoreAdaptor;
}
export function convertToRestoreRulesFormat(rules: Rule[]): string {
const iptablesRestore = ['# iptables-restore -- Balena Firewall'];
// build rules for each table we have rules for...
const tables = _(rules)
.groupBy((rule) => rule.table ?? 'filter')
.value();
// for each table, build the rules...
for (const table of Object.keys(tables)) {
iptablesRestore.push(`*${table}`);
// define our chains for this table...
tables[table]
.map((rule) => rule.chain)
.filter((chain, index, self) => {
if (
chain === undefined ||
['INPUT', 'FORWARD', 'OUTPUT'].includes(chain)
) {
return false;
}
return self.indexOf(chain) === index;
})
.forEach((chain) => {
iptablesRestore.push(`:${chain} - [0:0]`);
});
// add the rules...
tables[table]
.map((rule) => {
const args: string[] = [];
if (rule.action) {
args.push(rule.action);
}
if (rule.chain) {
args.push(rule.chain);
}
if (rule.proto) {
args.push(`-p ${rule.proto}`);
}
if (rule.matches) {
rule.matches.forEach((match) => args.push(match));
}
if (rule.comment) {
args.push('-m comment');
args.push(`--comment "${rule.comment}"`);
}
if (rule.target) {
args.push(`-j ${rule.target}`);
}
return args.join(' ');
})
.forEach((rule) => iptablesRestore.push(rule));
}
// commit the changes...
iptablesRestore.push('COMMIT');
// join the rules into a single string...
iptablesRestore.push('');
return iptablesRestore.join('\n');
}
/**
* Applies `iptables` rules, using `iptables-restore`, generated from a collection of Rules.
*
* E.g.
*
* ```iptables
* # iptables-restore format
* *<table>
* :<chain> <policy> [<packets_count>:<bytes_count>]
* <optional_counter><rule>
* ... more rules ...
* COMMIT
* ```
*
*
*
* @param {Rule[]} rules
*/
const iptablesRestoreAdaptor: RuleAdaptor = async (
rules: Rule[],
): Promise<void> => {
const rulesFiles = _(rules)
.groupBy((rule) => `v${rule.family}`)
.mapValues((ruleset) => convertToRestoreRulesFormat(ruleset))
.value();
// run the iptables-restore command...
for (const family of Object.getOwnPropertyNames(rulesFiles)) {
if (!['v4', 'v6'].includes(family)) {
return;
}
const input = rulesFiles[family];
const cmd = family === 'v6' ? 'ip6tables-restore' : 'iptables-restore';
await new Promise<string>((resolve, reject) => {
const args = ['--noflush', '--verbose'];
// prepare to pipe the rules into iptables-restore...
const stdinStream = new Readable();
stdinStream.push(input);
stdinStream.push(null);
// run the restore...
const proc = child_process.spawn(cmd, args, { shell: true });
// pipe the rules...
stdinStream.pipe(proc.stdin);
// grab any output from the command...
const stdout: string[] = [];
proc.stdout?.on('data', (data: Buffer) => {
stdout.push(data.toString('utf8'));
});
const stderr: string[] = [];
proc.stderr?.on('data', (data: Buffer) => {
stderr.push(data.toString('utf8'));
});
// handle close/error with the promise...
proc.on('error', (err) => reject(err));
proc.on('close', (code) => {
if (code && code !== 0) {
return reject(
new IPTablesRuleError(
`Error running iptables: ${stderr.join()} (${args.join(' ')})`,
),
);
}
return resolve(stdout.join());
});
});
}
};
/**
* Returns a builder structure for creating chains of `iptables` rules.
*
* @example
* ```
* build()
* .forTable('filter', filter => {
* filter.forChain('INPUT', chain => {
* chain.addRule({...});
* })
* })
* .apply(adaptor);
* ```
*
* @export
* @returns {TableBuilder}
*/
export function build(): TableBuilder {
const rules: Rule[] = [];
const tableBuilder: TableBuilder = {
forTable: (table, tableCtx) => {
const chainBuilder: ChainBuilder = {
forChain: (chain, chainCtx) => {
const ruleBuilder: RuleBuilder = {
addRule: (r: Rule) => {
const newRules = _.castArray(r);
rules.push(
...newRules.map((rule) => {
return {
...rule,
...{
chain,
table,
},
};
}),
);
return ruleBuilder;
},
};
chainCtx(ruleBuilder);
return chainBuilder;
},
};
tableCtx(chainBuilder);
return tableBuilder;
},
apply: async (adaptor) => {
await applyRules(rules, adaptor);
},
};
return tableBuilder;
}
function clearIptablesRule(rule: string): Bluebird<void> {
return applyIptablesArgs(`-D ${rule}`);
}
/**
* Applies the Rule(s) using the provided RuleAdaptor. You should always apply rules
* using this method, rather than directly through an adaptor. This is where any
* business logic will be done, as opposed to in the adaptor itself.
*
* @param {Rule|Rule[]} rules
* @param {RuleAdaptor} adaptor
* @returns
*/
async function applyRules(rules: Rule | Rule[], adaptor: RuleAdaptor) {
const processRule = (rule: Rule, collection: Rule[]) => {
// apply the rule to IPv6 and IPv4 unless a family is specified...
if (!rule.family) {
rule.family = 6;
function clearAndAppendIptablesRule(rule: string): Bluebird<void> {
return clearIptablesRule(rule)
.catchReturn(null)
.then(() => applyIptablesArgs(`-A ${rule}`));
}
// copy the rule, set the family and process as normal...
processRule(
{
...rule,
...{
family: 4,
},
},
collection,
);
}
function clearAndInsertIptablesRule(rule: string): Bluebird<void> {
return clearIptablesRule(rule)
.catchReturn(null)
.then(() => applyIptablesArgs(`-I ${rule}`));
}
collection.push(rule);
};
export function rejectOnAllInterfacesExcept(
allowedInterfaces: string[],
port: number,
): Bluebird<void> {
// We delete each rule and create it again to ensure ordering (all ACCEPTs before the REJECT/DROP).
// This is especially important after a supervisor update.
return Bluebird.each(allowedInterfaces, (iface) =>
clearAndInsertIptablesRule(
`INPUT -p tcp --dport ${port} -i ${iface} -j ACCEPT`,
),
)
.then(() =>
clearAndAppendIptablesRule(
`OUTPUT -p tcp --sport ${port} -m state --state ESTABLISHED -j ACCEPT`,
),
)
.then(() =>
clearAndAppendIptablesRule(`INPUT -p tcp --dport ${port} -j REJECT`),
);
}
const processedRules: Rule[] = [];
_.castArray(rules).forEach((rule) => processRule(rule, processedRules));
export function removeRejections(port: number): Bluebird<void> {
return clearIptablesRule(`INPUT -p tcp --dport ${port} -j REJECT`)
.catchReturn(null)
.then(() => clearIptablesRule(`INPUT -p tcp --dport ${port} -j DROP`))
.catchReturn(null)
.return();
await adaptor(processedRules);
}

View File

@ -7,8 +7,6 @@ import * as morgan from 'morgan';
import * as config from './config';
import * as eventTracker from './event-tracker';
import blink = require('./lib/blink');
import * as iptables from './lib/iptables';
import { checkTruthy } from './lib/validation';
import log from './lib/supervisor-console';
@ -90,14 +88,6 @@ export class SupervisorAPI {
private api = express();
private server: Server | null = null;
// Holds the function which should apply iptables rules
private applyRules: SupervisorAPI['applyListeningRules'] =
process.env.TEST === '1'
? () => {
// don't try to alter iptables
// rules while we're running in tests
}
: this.applyListeningRules.bind(this);
public constructor({ routers, healthchecks }: SupervisorAPIConstructOpts) {
this.routers = routers;
@ -174,25 +164,7 @@ export class SupervisorAPI {
);
}
public async listen(
allowedInterfaces: string[],
port: number,
apiTimeout: number,
): Promise<void> {
const localMode = await config.get('localMode');
await this.applyRules(localMode || false, port, allowedInterfaces);
// Monitor the switching of local mode, and change which interfaces will
// be listened to based on that
config.on('change', (changedConfig) => {
if (changedConfig.localMode != null) {
this.applyRules(
changedConfig.localMode || false,
port,
allowedInterfaces,
);
}
});
public async listen(port: number, apiTimeout: number): Promise<void> {
return new Promise((resolve) => {
this.server = this.api.listen(port, () => {
log.info(`Supervisor API successfully started on port ${port}`);
@ -204,25 +176,6 @@ export class SupervisorAPI {
});
}
private async applyListeningRules(
allInterfaces: boolean,
port: number,
allowedInterfaces: string[],
): Promise<void> {
try {
if (checkTruthy(allInterfaces)) {
await iptables.removeRejections(port);
log.debug('Supervisor API listening on all interfaces');
} else {
await iptables.rejectOnAllInterfacesExcept(allowedInterfaces, port);
log.debug('Supervisor API listening on allowed interfaces only');
}
} catch (err) {
log.error('Error on switching supervisor API listening rules', err);
return this.stop({ errored: true });
}
}
public async stop(options?: SupervisorAPIStopOpts): Promise<void> {
if (this.server != null) {
return new Promise((resolve, reject) => {

View File

@ -9,10 +9,11 @@ import * as osRelease from './lib/os-release';
import * as logger from './logger';
import SupervisorAPI from './supervisor-api';
import constants = require('./lib/constants');
import log from './lib/supervisor-console';
import version = require('./lib/supervisor-version');
import * as firewall from './lib/firewall';
const startupConfigFields: config.ConfigKey[] = [
'uuid',
'listenPort',
@ -71,6 +72,9 @@ export class Supervisor {
l4tVersion: await osRelease.getL4tVersion(),
});
log.info('Starting firewall');
await firewall.initialised;
log.debug('Starting api binder');
await this.apiBinder.initClient();
@ -86,11 +90,7 @@ export class Supervisor {
await this.deviceState.init();
log.info('Starting API server');
this.api.listen(
constants.allowedInterfaces,
conf.listenPort,
conf.apiTimeout,
);
this.api.listen(conf.listenPort, conf.apiTimeout);
this.deviceState.on('shutdown', () => this.api.stop());
await this.apiBinder.start();

View File

@ -6,6 +6,8 @@ process.env.DATABASE_PATH_2 = './test/data/database2.sqlite';
process.env.DATABASE_PATH_3 = './test/data/database3.sqlite';
process.env.LED_FILE = './test/data/led_file';
import './lib/mocked-iptables';
import * as dbus from 'dbus';
import { DBusError, DBusInterface } from 'dbus';
import { stub } from 'sinon';

View File

@ -41,6 +41,7 @@ const testTarget1 = {
name: 'aDevice',
config: {
HOST_CONFIG_gpu_mem: '256',
HOST_FIREWALL_MODE: 'off',
SUPERVISOR_CONNECTIVITY_CHECK: 'true',
SUPERVISOR_DELTA: 'false',
SUPERVISOR_DELTA_APPLY_TIMEOUT: '0',
@ -127,6 +128,7 @@ const testTargetWithDefaults2 = {
name: 'aDeviceWithDifferentName',
config: {
HOST_CONFIG_gpu_mem: '512',
HOST_FIREWALL_MODE: 'off',
SUPERVISOR_CONNECTIVITY_CHECK: 'true',
SUPERVISOR_DELTA: 'false',
SUPERVISOR_DELTA_APPLY_TIMEOUT: '0',

View File

@ -1,63 +0,0 @@
import * as Bluebird from 'bluebird';
import { stub } from 'sinon';
import { expect } from './lib/chai-config';
import * as iptables from '../src/lib/iptables';
describe('iptables', async () => {
it('calls iptables to delete and recreate rules to block a port', async () => {
stub(iptables, 'execAsync').returns(Bluebird.resolve(''));
await iptables.rejectOnAllInterfacesExcept(['foo', 'bar'], 42);
expect((iptables.execAsync as sinon.SinonStub).callCount).to.equal(16);
expect(iptables.execAsync).to.be.calledWith(
'iptables -D INPUT -p tcp --dport 42 -i foo -j ACCEPT',
);
expect(iptables.execAsync).to.be.calledWith(
'iptables -I INPUT -p tcp --dport 42 -i foo -j ACCEPT',
);
expect(iptables.execAsync).to.be.calledWith(
'iptables -D INPUT -p tcp --dport 42 -i bar -j ACCEPT',
);
expect(iptables.execAsync).to.be.calledWith(
'iptables -I INPUT -p tcp --dport 42 -i bar -j ACCEPT',
);
expect(iptables.execAsync).to.be.calledWith(
'iptables -D OUTPUT -p tcp --sport 42 -m state --state ESTABLISHED -j ACCEPT',
);
expect(iptables.execAsync).to.be.calledWith(
'iptables -A OUTPUT -p tcp --sport 42 -m state --state ESTABLISHED -j ACCEPT',
);
expect(iptables.execAsync).to.be.calledWith(
'iptables -D INPUT -p tcp --dport 42 -j REJECT',
);
expect(iptables.execAsync).to.be.calledWith(
'iptables -A INPUT -p tcp --dport 42 -j REJECT',
);
expect(iptables.execAsync).to.be.calledWith(
'ip6tables -D INPUT -p tcp --dport 42 -i foo -j ACCEPT',
);
expect(iptables.execAsync).to.be.calledWith(
'ip6tables -I INPUT -p tcp --dport 42 -i foo -j ACCEPT',
);
expect(iptables.execAsync).to.be.calledWith(
'ip6tables -D INPUT -p tcp --dport 42 -i bar -j ACCEPT',
);
expect(iptables.execAsync).to.be.calledWith(
'ip6tables -I INPUT -p tcp --dport 42 -i bar -j ACCEPT',
);
expect(iptables.execAsync).to.be.calledWith(
'ip6tables -D OUTPUT -p tcp --sport 42 -m state --state ESTABLISHED -j ACCEPT',
);
expect(iptables.execAsync).to.be.calledWith(
'ip6tables -A OUTPUT -p tcp --sport 42 -m state --state ESTABLISHED -j ACCEPT',
);
expect(iptables.execAsync).to.be.calledWith(
'ip6tables -D INPUT -p tcp --dport 42 -j REJECT',
);
expect(iptables.execAsync).to.be.calledWith(
'ip6tables -A INPUT -p tcp --dport 42 -j REJECT',
);
(iptables.execAsync as sinon.SinonStub).restore();
});
});

View File

@ -21,8 +21,6 @@ const { expect } = chai;
const defaultConfigBackend = config.configJsonBackend;
const initModels = async (obj: Dictionary<any>, filename: string) => {
await prepare();
config.removeAllListeners();
// @ts-expect-error setting read-only property
config.configJsonBackend = new ConfigJsonConfigBackend(schema, filename);
config.generateRequiredFields();

View File

@ -191,6 +191,7 @@ describe('Device Backend Config', () => {
it('returns default configuration values', () => {
const conf = deviceConfig.getDefaults();
return expect(conf).to.deep.equal({
HOST_FIREWALL_MODE: 'off',
SUPERVISOR_VPN_CONTROL: 'true',
SUPERVISOR_POLL_INTERVAL: '60000',
SUPERVISOR_LOCAL_MODE: 'false',

View File

@ -16,7 +16,6 @@ const mockedOptions = {
};
const VALID_SECRET = mockedAPI.STUBBED_VALUES.config.apiSecret;
const ALLOWED_INTERFACES = ['lo']; // Only need loopback since this is for testing
describe('SupervisorAPI', () => {
let api: SupervisorAPI;
@ -38,11 +37,7 @@ describe('SupervisorAPI', () => {
images.getStatus = () => Promise.resolve([]);
// Start test API
return api.listen(
ALLOWED_INTERFACES,
mockedOptions.listenPort,
mockedOptions.timeout,
);
return api.listen(mockedOptions.listenPort, mockedOptions.timeout);
});
after(async () => {
@ -216,20 +211,12 @@ describe('SupervisorAPI', () => {
// @ts-ignore
Log.error.restore();
// Resume API for other test suites
return api.listen(
ALLOWED_INTERFACES,
mockedOptions.listenPort,
mockedOptions.timeout,
);
return api.listen(mockedOptions.listenPort, mockedOptions.timeout);
});
it('logs successful start', async () => {
// Start API
await api.listen(
ALLOWED_INTERFACES,
mockedOptions.listenPort,
mockedOptions.timeout,
);
await api.listen(mockedOptions.listenPort, mockedOptions.timeout);
// Check if success start was logged
// @ts-ignore
expect(Log.info.lastCall?.lastArg).to.equal(
@ -239,11 +226,7 @@ describe('SupervisorAPI', () => {
it('logs shutdown', async () => {
// Start API
await api.listen(
ALLOWED_INTERFACES,
mockedOptions.listenPort,
mockedOptions.timeout,
);
await api.listen(mockedOptions.listenPort, mockedOptions.timeout);
// Stop API
await api.stop();
// Check if stopped with info was logged
@ -253,11 +236,7 @@ describe('SupervisorAPI', () => {
it('logs errored shutdown', async () => {
// Start API
await api.listen(
ALLOWED_INTERFACES,
mockedOptions.listenPort,
mockedOptions.timeout,
);
await api.listen(mockedOptions.listenPort, mockedOptions.timeout);
// Stop API with error
await api.stop({ errored: true });
// Check if stopped with error was logged

View File

@ -10,7 +10,6 @@ const mockedOptions = {
const VALID_SECRET = mockedAPI.STUBBED_VALUES.config.apiSecret;
const INVALID_SECRET = 'bad_api_secret';
const ALLOWED_INTERFACES = ['lo']; // Only need loopback since this is for testing
describe('SupervisorAPI authentication', () => {
let api: SupervisorAPI;
@ -20,11 +19,7 @@ describe('SupervisorAPI authentication', () => {
// Create test API
api = await mockedAPI.create();
// Start test API
return api.listen(
ALLOWED_INTERFACES,
mockedOptions.listenPort,
mockedOptions.timeout,
);
return api.listen(mockedOptions.listenPort, mockedOptions.timeout);
});
after(async () => {

309
test/29-firewall.spec.ts Normal file
View File

@ -0,0 +1,309 @@
import { spy } from 'sinon';
import { expect } from 'chai';
import * as Docker from 'dockerode';
import { docker } from '../src/lib/docker-utils';
import * as sinon from 'sinon';
import * as config from '../src/config';
import * as firewall from '../src/lib/firewall';
import * as iptablesMock from './lib/mocked-iptables';
import * as targetStateCache from '../src/device-state/target-state-cache';
import constants = require('../src/lib/constants');
import { RuleAction } from '../src/lib/iptables';
describe('Host Firewall', function () {
let apiEndpoint: string;
let listenPort: number;
let dockerStub: sinon.SinonStubbedInstance<typeof docker>;
before(async () => {
dockerStub = sinon.stub(docker);
dockerStub.listContainers.resolves([]);
dockerStub.listImages.resolves([]);
dockerStub.getImage.returns({
id: 'abcde',
inspect: async () => {
return {};
},
} as Docker.Image);
await targetStateCache.initialized;
await firewall.initialised;
apiEndpoint = await config.get('apiEndpoint');
listenPort = await config.get('listenPort');
});
after(async () => {
sinon.restore();
});
describe('Basic On/Off operation', () => {
it('should confirm the `changed` event is handled', async function () {
await iptablesMock.whilstMocked(async ({ hasAppliedRules }) => {
const changedSpy = spy();
config.on('change', changedSpy);
// set the firewall to be in off mode...
await config.set({ firewallMode: 'off' });
await hasAppliedRules;
// check it fired the events correctly...
expect(changedSpy.called).to.be.true;
expect(changedSpy.calledWith({ firewallMode: 'off' })).to.be.true;
});
});
it('should handle the HOST_FIREWALL_MODE configuration value: invalid', async function () {
await iptablesMock.whilstMocked(
async ({ hasAppliedRules, expectRule }) => {
// set the firewall to be in off mode...
await config.set({ firewallMode: 'invalid' });
await hasAppliedRules;
// expect that we jump to the firewall chain...
expectRule({
action: RuleAction.Append,
target: 'BALENA-FIREWALL',
chain: 'INPUT',
family: 4,
});
// expect to return...
expectRule({
action: RuleAction.Insert,
table: 'filter',
chain: 'BALENA-FIREWALL',
target: 'RETURN',
family: 4,
});
},
);
});
it('should respect the HOST_FIREWALL_MODE configuration value: off', async function () {
await iptablesMock.whilstMocked(
async ({ hasAppliedRules, expectRule }) => {
// set the firewall to be in off mode...
await config.set({ firewallMode: 'off' });
await hasAppliedRules;
// expect that we jump to the firewall chain...
expectRule({
action: RuleAction.Append,
target: 'BALENA-FIREWALL',
chain: 'INPUT',
family: 4,
});
// expect to return...
expectRule({
action: RuleAction.Insert,
table: 'filter',
chain: 'BALENA-FIREWALL',
target: 'RETURN',
family: 4,
});
},
);
});
it('should respect the HOST_FIREWALL_MODE configuration value: on', async function () {
await iptablesMock.whilstMocked(
async ({ hasAppliedRules, expectRule, expectNoRule }) => {
// set the firewall to be in auto mode...
await config.set({ firewallMode: 'on' });
await hasAppliedRules;
// expect that we DO have a rule to use the chain...
expectRule({
action: RuleAction.Append,
target: 'BALENA-FIREWALL',
chain: 'INPUT',
family: 4,
});
// expect to not return...
expectNoRule({
action: RuleAction.Insert,
table: 'filter',
chain: 'BALENA-FIREWALL',
target: 'RETURN',
family: 4,
});
},
);
});
it('should respect the HOST_FIREWALL_MODE configuration value: auto (no services in host-network)', async function () {
await iptablesMock.whilstMocked(
async ({ hasAppliedRules, expectRule }) => {
await targetStateCache.setTargetApps([
{
appId: 2,
commit: 'abcdef2',
name: 'test-app2',
source: apiEndpoint,
releaseId: 1232,
services: JSON.stringify([
{
serviceName: 'test-service',
image: 'test-image',
imageId: 5,
environment: {
TEST_VAR: 'test-string',
},
tty: true,
appId: 2,
releaseId: 1232,
serviceId: 567,
commit: 'abcdef2',
},
]),
networks: '{}',
volumes: '{}',
},
]);
// set the firewall to be in auto mode...
await config.set({ firewallMode: 'auto' });
await hasAppliedRules;
// expect that we DO have a rule to use the chain...
expectRule({
action: RuleAction.Append,
target: 'BALENA-FIREWALL',
chain: 'INPUT',
family: 4,
});
// expect to return...
expectRule({
action: RuleAction.Insert,
table: 'filter',
chain: 'BALENA-FIREWALL',
target: 'RETURN',
family: 4,
});
},
);
});
it('should respect the HOST_FIREWALL_MODE configuration value: auto (service in host-network)', async function () {
await iptablesMock.whilstMocked(
async ({ hasAppliedRules, expectRule, expectNoRule }) => {
await targetStateCache.setTargetApps([
{
appId: 2,
commit: 'abcdef2',
name: 'test-app2',
source: apiEndpoint,
releaseId: 1232,
services: JSON.stringify([
{
serviceName: 'test-service',
networkMode: 'host',
image: 'test-image',
imageId: 5,
environment: {
TEST_VAR: 'test-string',
},
tty: true,
appId: 2,
releaseId: 1232,
serviceId: 567,
commit: 'abcdef2',
},
]),
networks: '{}',
volumes: '{}',
},
]);
// set the firewall to be in auto mode...
await config.set({ firewallMode: 'auto' });
await hasAppliedRules;
// expect that we DO have a rule to use the chain...
expectRule({
action: RuleAction.Append,
target: 'BALENA-FIREWALL',
chain: 'INPUT',
family: 4,
});
// expect to return...
expectNoRule({
action: RuleAction.Insert,
table: 'filter',
chain: 'BALENA-FIREWALL',
target: 'RETURN',
family: 4,
});
},
);
});
});
describe('Supervisor API access', () => {
it('should allow access in localmode', async function () {
await iptablesMock.whilstMocked(
async ({ hasAppliedRules, expectRule }) => {
// set the device to be in local mode...
await config.set({ localMode: true });
await hasAppliedRules;
// make sure we have a rule to allow traffic on ANY interface
[4, 6].forEach((family: 4 | 6) => {
expectRule({
action: RuleAction.Append,
proto: 'tcp',
matches: [`--dport ${listenPort}`],
target: 'ACCEPT',
chain: 'BALENA-FIREWALL',
table: 'filter',
family,
});
});
},
);
});
it('should allow limited access in non-localmode', async function () {
await iptablesMock.whilstMocked(
async ({ hasAppliedRules, expectRule, expectNoRule }) => {
// set the device to be in local mode...
await config.set({ localMode: false });
await hasAppliedRules;
// ensure we have no unrestricted rule...
expectNoRule({
action: RuleAction.Append,
chain: 'BALENA-FIREWALL',
table: 'filter',
proto: 'tcp',
matches: [`--dport ${listenPort}`],
target: 'ACCEPT',
family: 4,
});
// ensure we do have a restricted rule for each interface...
constants.allowedInterfaces.forEach((intf) => {
[4, 6].forEach((family: 4 | 6) => {
expectRule({
action: RuleAction.Append,
chain: 'BALENA-FIREWALL',
table: 'filter',
proto: 'tcp',
matches: [`--dport ${listenPort}`, `-i ${intf}`],
target: 'ACCEPT',
family,
});
});
});
},
);
});
});
});

119
test/lib/mocked-iptables.ts Normal file
View File

@ -0,0 +1,119 @@
import _ = require('lodash');
import { expect } from 'chai';
import * as firewall from '../../src/lib/firewall';
import * as iptables from '../../src/lib/iptables';
import { EventEmitter } from 'events';
class FakeRuleAdaptor {
private rules: iptables.Rule[];
constructor() {
this.rules = [];
}
public getRuleAdaptor(): iptables.RuleAdaptor {
return this.ruleAdaptor.bind(this);
}
private async ruleAdaptor(rules: iptables.Rule[]): Promise<void> {
const handleRule = async (rule: iptables.Rule) => {
// remove any undefined values from the object...
for (const key of Object.getOwnPropertyNames(rule)) {
if ((rule as any)[key] === undefined) {
delete (rule as any)[key];
}
}
this.rules.push(rule);
return '';
};
if (_.isArray(rules)) {
for (const rule of rules) {
await handleRule(rule);
}
}
}
private isSameRule(
partial: Partial<iptables.Rule>,
rule: iptables.Rule,
): boolean {
const props = Object.getOwnPropertyNames(partial);
for (const prop of props) {
if (
_.get(rule, prop) === undefined ||
!_.isEqual(_.get(rule, prop), _.get(partial, prop))
) {
return false;
}
}
return true;
}
public expectRule(testRule: Partial<iptables.Rule>) {
return expect(
_.some(this.rules, (r) => this.isSameRule(testRule, r)),
).to.eq(
true,
`Rule has not been applied: ${JSON.stringify(
testRule,
)}\n\n${JSON.stringify(this.rules, null, 2)}`,
);
}
public expectNoRule(testRule: Partial<iptables.Rule>) {
return expect(
_.some(this.rules, (r) => this.isSameRule(testRule, r)),
).to.eq(
false,
`Rule has been applied: ${JSON.stringify(testRule)}\n\n${JSON.stringify(
this.rules,
null,
2,
)}`,
);
}
public clearHistory() {
this.rules = [];
}
}
const fakeRuleAdaptor = new FakeRuleAdaptor();
// @ts-expect-error Assigning to a RO property
iptables.getDefaultRuleAdaptor = () => fakeRuleAdaptor.getRuleAdaptor();
export interface MockedState {
hasAppliedRules: Promise<void>;
expectRule: (rule: iptables.Rule) => void;
expectNoRule: (rule: iptables.Rule) => void;
clearHistory: () => void;
}
export type MockedConext = (state: MockedState) => Promise<any>;
const applyFirewallRules = firewall.applyFirewallMode;
export const whilstMocked = async (context: MockedConext) => {
fakeRuleAdaptor.clearHistory();
const applied = new EventEmitter();
// @ts-expect-error Assigning to a RO property
firewall.applyFirewallMode = async (mode: string) => {
await applyFirewallRules(mode);
applied.emit('applied');
};
await context({
expectRule: (rule) => fakeRuleAdaptor.expectRule(rule),
expectNoRule: (rule) => fakeRuleAdaptor.expectNoRule(rule),
clearHistory: () => fakeRuleAdaptor.clearHistory(),
hasAppliedRules: new Promise((resolve) => {
applied.once('applied', () => resolve());
}),
});
// @ts-expect-error Assigning to a RO property
firewall.applyFirewallMode = applyFirewallRules;
};