mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2025-02-20 17:52:51 +00:00
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:
parent
fc70b1c6f8
commit
28c5a44e71
@ -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,
|
||||
|
@ -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
|
||||
|
@ -185,6 +185,11 @@ export const schema = {
|
||||
mutable: true,
|
||||
removeIfNull: false,
|
||||
},
|
||||
firewallMode: {
|
||||
source: 'db',
|
||||
mutable: true,
|
||||
removeIfNull: false,
|
||||
},
|
||||
};
|
||||
|
||||
export type Schema = typeof schema;
|
||||
|
@ -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
192
src/lib/firewall.ts
Normal 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`);
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
@ -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) => {
|
||||
|
@ -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();
|
||||
|
@ -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';
|
||||
|
@ -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',
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
@ -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();
|
||||
|
@ -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',
|
||||
|
@ -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
|
||||
|
@ -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
309
test/29-firewall.spec.ts
Normal 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
119
test/lib/mocked-iptables.ts
Normal 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;
|
||||
};
|
Loading…
x
Reference in New Issue
Block a user