import _ = require('lodash'); import { expect } from 'chai'; import * as Docker from 'dockerode'; import { docker } from '~/lib/docker-utils'; import * as sinon from 'sinon'; import * as config from '~/src/config'; import * as firewall from '~/lib/firewall'; import * as logger from '~/src/logger'; import * as iptablesMock from '~/test-lib/mocked-iptables'; import * as targetStateCache from '~/src/device-state/target-state-cache'; import constants = require('~/lib/constants'); import { RuleAction, Rule } from '~/lib/iptables'; import { log } from '~/lib/supervisor-console'; describe('Host Firewall', function () { const dockerStubs: Dictionary = {}; let loggerSpy: sinon.SinonSpy; let logSpy: sinon.SinonSpy; let apiEndpoint: string; let listenPort: number; before(async () => { // spy the logs... loggerSpy = sinon.spy(logger, 'logSystemMessage'); logSpy = sinon.spy(log, 'error'); // stub the docker calls... dockerStubs.listContainers = sinon .stub(docker, 'listContainers') .resolves([]); dockerStubs.listImages = sinon.stub(docker, 'listImages').resolves([]); dockerStubs.getImage = sinon.stub(docker, '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 () => { for (const stub of _.values(dockerStubs)) { stub.restore(); } loggerSpy.restore(); logSpy.restore(); }); describe('Basic On/Off operation', () => { it('should confirm the `changed` event is handled', async function () { await iptablesMock.whilstMocked(async ({ hasAppliedRules }) => { const changedSpy = sinon.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({ target: 'BALENA-FIREWALL', chain: 'INPUT', family: 4, }); // expect to return... expectRule({ 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... const returnRuleIdx = expectRule({ table: 'filter', chain: 'BALENA-FIREWALL', target: 'RETURN', family: 4, }); // ... just before we reject everything const rejectRuleIdx = expectRule({ chain: 'BALENA-FIREWALL', target: 'REJECT', matches: iptablesMock.RuleProperty.NotSet, family: 4, }); expect(returnRuleIdx).to.be.lessThan(rejectRuleIdx); }, ); }); 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 jump to the firewall chain... expectRule({ action: RuleAction.Append, target: 'BALENA-FIREWALL', chain: 'INPUT', family: 4, }); // expect to not return for any reason... expectNoRule({ chain: 'BALENA-FIREWALL', target: 'RETURN', }); }, ); }); 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, uuid: 'myapp', commit: 'abcdef2', name: 'test-app2', class: 'fleet', source: apiEndpoint, releaseId: 1232, isHost: false, 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 jump to the firewall chain... expectRule({ action: RuleAction.Append, target: 'BALENA-FIREWALL', chain: 'INPUT', family: 4, }); // expect to return... expectRule({ 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, uuid: 'myapp', commit: 'abcdef2', name: 'test-app2', source: apiEndpoint, class: 'fleet', releaseId: 1232, isHost: false, services: JSON.stringify([ { serviceName: 'test-service', image: 'test-image', imageId: 5, environment: { TEST_VAR: 'test-string', }, appId: 2, releaseId: 1232, serviceId: 567, commit: 'abcdef2', composition: { tty: true, network_mode: 'host', }, }, ]), networks: '[]', volumes: '[]', }, ]); // set the firewall to be in auto mode... await config.set({ firewallMode: 'auto' }); await hasAppliedRules; // expect that we jump to the firewall chain... expectRule({ action: RuleAction.Append, target: 'BALENA-FIREWALL', chain: 'INPUT', family: 4, }); // expect to return... expectNoRule({ chain: 'BALENA-FIREWALL', target: 'RETURN', family: 4, }); }, ); }); it('should catch errors when rule changes fail', async () => { await iptablesMock.whilstMocked(async ({ hasAppliedRules }) => { // clear the spies... loggerSpy.resetHistory(); logSpy.resetHistory(); // set the firewall to be in off mode... await config.set({ firewallMode: 'off' }); await hasAppliedRules; // should have caught the error and logged it expect(logSpy.calledWith('Error applying firewall mode')).to.be.true; expect(loggerSpy.called).to.be.true; }, iptablesMock.realRuleAdaptor); }); }); describe('Service rules', () => { const rejectAllRule = { target: 'REJECT', chain: 'BALENA-FIREWALL', matches: iptablesMock.RuleProperty.NotSet, }; const checkForRules = ( rules: Array> | iptablesMock.Testable, expectRule: (rule: iptablesMock.Testable) => number, ) => { rules = _.castArray(rules); rules.forEach((rule) => { const ruleIdx = expectRule(rule); // make sure we reject AFTER the rule... const rejectAllRuleIdx = expectRule(rejectAllRule); expect(ruleIdx).is.lessThan(rejectAllRuleIdx); }); }; it('should have a rule to allow DNS traffic from the balena0 interface', async () => { await iptablesMock.whilstMocked( async ({ hasAppliedRules, expectRule }) => { // set the firewall to be on... await config.set({ firewallMode: 'on' }); await hasAppliedRules; [4, 6].forEach((family: 4 | 6) => { // expect that we have a rule to allow DNS access... checkForRules( { family, target: 'ACCEPT', chain: 'BALENA-FIREWALL', proto: 'udp', matches: ['--dport 53', '-i balena0'], }, expectRule, ); }); }, ); }); it('should have a rule to allow SSH traffic any interface', async () => { await iptablesMock.whilstMocked( async ({ hasAppliedRules, expectRule }) => { // set the firewall to be on... await config.set({ firewallMode: 'on' }); await hasAppliedRules; [4, 6].forEach((family: 4 | 6) => { // expect that we have a rule to allow SSH access... checkForRules( { family, target: 'ACCEPT', chain: 'BALENA-FIREWALL', proto: 'tcp', matches: ['--dport 22222'], }, expectRule, ); }); }, ); }); it('should have a rule to allow Multicast traffic any interface', async () => { await iptablesMock.whilstMocked( async ({ hasAppliedRules, expectRule }) => { // set the firewall to be on... await config.set({ firewallMode: 'on' }); await hasAppliedRules; [4, 6].forEach((family: 4 | 6) => { // expect that we have a rule to allow multicast... checkForRules( { family, target: 'ACCEPT', chain: 'BALENA-FIREWALL', matches: ['-m addrtype', '--dst-type MULTICAST'], }, expectRule, ); }); }, ); }); it('should have a rule to allow balenaEngine traffic any interface', async () => { await iptablesMock.whilstMocked( async ({ hasAppliedRules, expectRule }) => { // set the firewall to be on... await config.set({ firewallMode: 'on' }); await hasAppliedRules; [4, 6].forEach((family: 4 | 6) => { // expect that we have a rule to allow balenaEngine access... checkForRules( { family, target: 'ACCEPT', chain: 'BALENA-FIREWALL', proto: 'tcp', matches: ['--dport 2375'], }, expectRule, ); }); }, ); }); }); 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; [4, 6].forEach((family: 4 | 6) => { // make sure we have a rule to allow traffic on ANY interface const allowRuleIdx = expectRule({ proto: 'tcp', matches: [`--dport ${listenPort}`], target: 'ACCEPT', chain: 'BALENA-FIREWALL', table: 'filter', family, }); // make sure we have a rule to block traffic on ANY interface also const rejectRuleIdx = expectRule({ proto: 'tcp', matches: [`--dport ${listenPort}`], target: 'REJECT', chain: 'BALENA-FIREWALL', table: 'filter', family, }); // we should always reject AFTER we allow expect(allowRuleIdx).to.be.lessThan(rejectRuleIdx); }); }, ); }); 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({ chain: 'BALENA-FIREWALL', proto: 'tcp', matches: [`--dport ${listenPort}`], target: 'ACCEPT', family: 4, }); // ensure we do have a restricted rule for each interface... let allowRuleIdx = -1; constants.allowedInterfaces.forEach((intf) => { [4, 6].forEach((family: 4 | 6) => { allowRuleIdx = expectRule({ chain: 'BALENA-FIREWALL', proto: 'tcp', matches: [`--dport ${listenPort}`, `-i ${intf}`], target: 'ACCEPT', family, }); }); }); // make sure we have a rule to block traffic on ANY interface also const rejectRuleIdx = expectRule({ proto: 'tcp', matches: [`--dport ${listenPort}`], target: 'REJECT', chain: 'BALENA-FIREWALL', table: 'filter', }); // we should always reject AFTER we allow expect(allowRuleIdx).to.be.lessThan(rejectRuleIdx); }, ); }); }); });