From 28c5a44e714a3f155fbd528d0ec672dec1d96ef8 Mon Sep 17 00:00:00 2001 From: Rich Bayliss Date: Mon, 15 Jun 2020 17:46:33 +0100 Subject: [PATCH] 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 --- src/config/index.ts | 3 - src/config/schema-type.ts | 4 + src/config/schema.ts | 5 + src/device-config.ts | 7 + src/lib/firewall.ts | 192 ++++++++++++++++ src/lib/iptables.ts | 332 +++++++++++++++++++++++----- src/supervisor-api.ts | 49 +--- src/supervisor.ts | 12 +- test/00-init.ts | 2 + test/05-device-state.spec.ts | 2 + test/06-iptables.spec.ts | 63 ------ test/11-api-binder.spec.ts | 2 - test/13-device-config.spec.ts | 1 + test/21-supervisor-api.spec.ts | 31 +-- test/26-supervisor-api-auth.spec.ts | 7 +- test/29-firewall.spec.ts | 309 ++++++++++++++++++++++++++ test/lib/mocked-iptables.ts | 119 ++++++++++ 17 files changed, 930 insertions(+), 210 deletions(-) create mode 100644 src/lib/firewall.ts delete mode 100644 test/06-iptables.spec.ts create mode 100644 test/29-firewall.spec.ts create mode 100644 test/lib/mocked-iptables.ts diff --git a/src/config/index.ts b/src/config/index.ts index 25eacda0..4da88c83 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -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( key: T, diff --git a/src/config/schema-type.ts b/src/config/schema-type.ts index 07418dbe..9c8f3a9a 100644 --- a/src/config/schema-type.ts +++ b/src/config/schema-type.ts @@ -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 diff --git a/src/config/schema.ts b/src/config/schema.ts index 5c3638f8..6e134c2e 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -185,6 +185,11 @@ export const schema = { mutable: true, removeIfNull: false, }, + firewallMode: { + source: 'db', + mutable: true, + removeIfNull: false, + }, }; export type Schema = typeof schema; diff --git a/src/device-config.ts b/src/device-config.ts index 8170b995..026a1c71 100644 --- a/src/device-config.ts +++ b/src/device-config.ts @@ -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'); } diff --git a/src/lib/firewall.ts b/src/lib/firewall.ts new file mode 100644 index 00000000..a467ed9b --- /dev/null +++ b/src/lib/firewall.ts @@ -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 { + 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`); +} diff --git a/src/lib/iptables.ts b/src/lib/iptables.ts index 42de4a88..3918a627 100644 --- a/src/lib/iptables.ts +++ b/src/lib/iptables.ts @@ -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 = Bluebird.promisify( - childProcess.exec, -); +export class IPTablesRuleError extends Error {} -function applyIptablesArgs(args: string): Bluebird { - 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; +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; +} +/** + * 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 + * * + * : [:] + * + * ... more rules ... + * COMMIT + * ``` + * + * + * + * @param {Rule[]} rules + */ +const iptablesRestoreAdaptor: RuleAdaptor = async ( + rules: Rule[], +): Promise => { + 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((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 { - 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 { - 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 { - return clearIptablesRule(rule) - .catchReturn(null) - .then(() => applyIptablesArgs(`-I ${rule}`)); -} + collection.push(rule); + }; -export function rejectOnAllInterfacesExcept( - allowedInterfaces: string[], - port: number, -): Bluebird { - // 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 { - 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); } diff --git a/src/supervisor-api.ts b/src/supervisor-api.ts index d7f56286..de0c3ed6 100644 --- a/src/supervisor-api.ts +++ b/src/supervisor-api.ts @@ -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 { - 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 { 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 { - 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 { if (this.server != null) { return new Promise((resolve, reject) => { diff --git a/src/supervisor.ts b/src/supervisor.ts index 0760ad86..a4d4c6eb 100644 --- a/src/supervisor.ts +++ b/src/supervisor.ts @@ -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(); diff --git a/test/00-init.ts b/test/00-init.ts index 535776cb..2904f883 100644 --- a/test/00-init.ts +++ b/test/00-init.ts @@ -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'; diff --git a/test/05-device-state.spec.ts b/test/05-device-state.spec.ts index 46323c0e..4658910d 100644 --- a/test/05-device-state.spec.ts +++ b/test/05-device-state.spec.ts @@ -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', diff --git a/test/06-iptables.spec.ts b/test/06-iptables.spec.ts deleted file mode 100644 index fae510aa..00000000 --- a/test/06-iptables.spec.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/test/11-api-binder.spec.ts b/test/11-api-binder.spec.ts index c45fca83..77b36739 100644 --- a/test/11-api-binder.spec.ts +++ b/test/11-api-binder.spec.ts @@ -21,8 +21,6 @@ const { expect } = chai; const defaultConfigBackend = config.configJsonBackend; const initModels = async (obj: Dictionary, filename: string) => { await prepare(); - config.removeAllListeners(); - // @ts-expect-error setting read-only property config.configJsonBackend = new ConfigJsonConfigBackend(schema, filename); config.generateRequiredFields(); diff --git a/test/13-device-config.spec.ts b/test/13-device-config.spec.ts index 30b3e30a..bb4940bc 100644 --- a/test/13-device-config.spec.ts +++ b/test/13-device-config.spec.ts @@ -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', diff --git a/test/21-supervisor-api.spec.ts b/test/21-supervisor-api.spec.ts index 3f31b7d6..d5d58c5d 100644 --- a/test/21-supervisor-api.spec.ts +++ b/test/21-supervisor-api.spec.ts @@ -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 diff --git a/test/26-supervisor-api-auth.spec.ts b/test/26-supervisor-api-auth.spec.ts index d9cfb99c..3e135475 100644 --- a/test/26-supervisor-api-auth.spec.ts +++ b/test/26-supervisor-api-auth.spec.ts @@ -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 () => { diff --git a/test/29-firewall.spec.ts b/test/29-firewall.spec.ts new file mode 100644 index 00000000..ad7f9adb --- /dev/null +++ b/test/29-firewall.spec.ts @@ -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; + + 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, + }); + }); + }); + }, + ); + }); + }); +}); diff --git a/test/lib/mocked-iptables.ts b/test/lib/mocked-iptables.ts new file mode 100644 index 00000000..9ade9b91 --- /dev/null +++ b/test/lib/mocked-iptables.ts @@ -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 { + 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, + 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) { + 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) { + 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; + expectRule: (rule: iptables.Rule) => void; + expectNoRule: (rule: iptables.Rule) => void; + clearHistory: () => void; +} + +export type MockedConext = (state: MockedState) => Promise; + +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; +};