mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2025-02-22 02:16:43 +00:00
Merge pull request #1380 from balena-io/host-firewall-support
Add host firewall
This commit is contained in:
commit
e9fa8ab4e9
@ -49,9 +49,6 @@ export const once: typeof events['once'] = events.once.bind(events);
|
|||||||
export const removeListener: typeof events['removeListener'] = events.removeListener.bind(
|
export const removeListener: typeof events['removeListener'] = events.removeListener.bind(
|
||||||
events,
|
events,
|
||||||
);
|
);
|
||||||
export const removeAllListeners: typeof events['removeAllListeners'] = events.removeAllListeners.bind(
|
|
||||||
events,
|
|
||||||
);
|
|
||||||
|
|
||||||
export async function get<T extends SchemaTypeKey>(
|
export async function get<T extends SchemaTypeKey>(
|
||||||
key: T,
|
key: T,
|
||||||
|
@ -166,6 +166,10 @@ export const schemaTypes = {
|
|||||||
type: PermissiveBoolean,
|
type: PermissiveBoolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
firewallMode: {
|
||||||
|
type: t.string,
|
||||||
|
default: NullOrUndefined,
|
||||||
|
},
|
||||||
|
|
||||||
// Function schema types
|
// Function schema types
|
||||||
// The type should be the value that the promise resolves
|
// The type should be the value that the promise resolves
|
||||||
|
@ -185,6 +185,11 @@ export const schema = {
|
|||||||
mutable: true,
|
mutable: true,
|
||||||
removeIfNull: false,
|
removeIfNull: false,
|
||||||
},
|
},
|
||||||
|
firewallMode: {
|
||||||
|
source: 'db',
|
||||||
|
mutable: true,
|
||||||
|
removeIfNull: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Schema = typeof schema;
|
export type Schema = typeof schema;
|
||||||
|
@ -122,6 +122,11 @@ export class DeviceConfig {
|
|||||||
defaultValue: 'false',
|
defaultValue: 'false',
|
||||||
rebootRequired: true,
|
rebootRequired: true,
|
||||||
},
|
},
|
||||||
|
firewallMode: {
|
||||||
|
envVarName: 'HOST_FIREWALL_MODE',
|
||||||
|
varType: 'string',
|
||||||
|
defaultValue: 'off',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
public static validKeys = [
|
public static validKeys = [
|
||||||
@ -580,6 +585,8 @@ export class DeviceConfig {
|
|||||||
return checkTruthy(a) === checkTruthy(b);
|
return checkTruthy(a) === checkTruthy(b);
|
||||||
case 'int':
|
case 'int':
|
||||||
return checkInt(a) === checkInt(b);
|
return checkInt(a) === checkInt(b);
|
||||||
|
case 'string':
|
||||||
|
return a === b;
|
||||||
default:
|
default:
|
||||||
throw new Error('Incorrect datatype passed to DeviceConfig.configTest');
|
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 _ from 'lodash';
|
||||||
import * as childProcess from 'child_process';
|
import { child_process } from 'mz';
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
|
||||||
// The following is exported so that we stub it in the tests
|
export class IPTablesRuleError extends Error {}
|
||||||
export const execAsync: (args: string) => Bluebird<string> = Bluebird.promisify(
|
|
||||||
childProcess.exec,
|
|
||||||
);
|
|
||||||
|
|
||||||
function applyIptablesArgs(args: string): Bluebird<void> {
|
export enum RuleAction {
|
||||||
let err: Error | null = null;
|
Insert = '-I',
|
||||||
// We want to run both commands regardless, but also rethrow an error
|
Append = '-A',
|
||||||
// if one of them fails
|
Flush = '-F',
|
||||||
return execAsync(`iptables ${args}`)
|
}
|
||||||
.catch((e) => (err = e))
|
export interface Rule {
|
||||||
.then(() => execAsync(`ip6tables ${args}`).catch((e) => (err = e)))
|
id?: number;
|
||||||
.then(() => {
|
family?: 4 | 6;
|
||||||
if (err != null) {
|
action?: RuleAction;
|
||||||
throw err;
|
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> {
|
// copy the rule, set the family and process as normal...
|
||||||
return clearIptablesRule(rule)
|
processRule(
|
||||||
.catchReturn(null)
|
{
|
||||||
.then(() => applyIptablesArgs(`-A ${rule}`));
|
...rule,
|
||||||
}
|
...{
|
||||||
|
family: 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
collection,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function clearAndInsertIptablesRule(rule: string): Bluebird<void> {
|
collection.push(rule);
|
||||||
return clearIptablesRule(rule)
|
};
|
||||||
.catchReturn(null)
|
|
||||||
.then(() => applyIptablesArgs(`-I ${rule}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function rejectOnAllInterfacesExcept(
|
const processedRules: Rule[] = [];
|
||||||
allowedInterfaces: string[],
|
_.castArray(rules).forEach((rule) => processRule(rule, processedRules));
|
||||||
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`),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeRejections(port: number): Bluebird<void> {
|
await adaptor(processedRules);
|
||||||
return clearIptablesRule(`INPUT -p tcp --dport ${port} -j REJECT`)
|
|
||||||
.catchReturn(null)
|
|
||||||
.then(() => clearIptablesRule(`INPUT -p tcp --dport ${port} -j DROP`))
|
|
||||||
.catchReturn(null)
|
|
||||||
.return();
|
|
||||||
}
|
}
|
||||||
|
@ -7,8 +7,6 @@ import * as morgan from 'morgan';
|
|||||||
import * as config from './config';
|
import * as config from './config';
|
||||||
import * as eventTracker from './event-tracker';
|
import * as eventTracker from './event-tracker';
|
||||||
import blink = require('./lib/blink');
|
import blink = require('./lib/blink');
|
||||||
import * as iptables from './lib/iptables';
|
|
||||||
import { checkTruthy } from './lib/validation';
|
|
||||||
|
|
||||||
import log from './lib/supervisor-console';
|
import log from './lib/supervisor-console';
|
||||||
|
|
||||||
@ -90,14 +88,6 @@ export class SupervisorAPI {
|
|||||||
|
|
||||||
private api = express();
|
private api = express();
|
||||||
private server: Server | null = null;
|
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) {
|
public constructor({ routers, healthchecks }: SupervisorAPIConstructOpts) {
|
||||||
this.routers = routers;
|
this.routers = routers;
|
||||||
@ -174,25 +164,7 @@ export class SupervisorAPI {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async listen(
|
public async listen(port: number, apiTimeout: number): Promise<void> {
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
this.server = this.api.listen(port, () => {
|
this.server = this.api.listen(port, () => {
|
||||||
log.info(`Supervisor API successfully started on port ${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> {
|
public async stop(options?: SupervisorAPIStopOpts): Promise<void> {
|
||||||
if (this.server != null) {
|
if (this.server != null) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
@ -9,10 +9,11 @@ import * as osRelease from './lib/os-release';
|
|||||||
import * as logger from './logger';
|
import * as logger from './logger';
|
||||||
import SupervisorAPI from './supervisor-api';
|
import SupervisorAPI from './supervisor-api';
|
||||||
|
|
||||||
import constants = require('./lib/constants');
|
|
||||||
import log from './lib/supervisor-console';
|
import log from './lib/supervisor-console';
|
||||||
import version = require('./lib/supervisor-version');
|
import version = require('./lib/supervisor-version');
|
||||||
|
|
||||||
|
import * as firewall from './lib/firewall';
|
||||||
|
|
||||||
const startupConfigFields: config.ConfigKey[] = [
|
const startupConfigFields: config.ConfigKey[] = [
|
||||||
'uuid',
|
'uuid',
|
||||||
'listenPort',
|
'listenPort',
|
||||||
@ -71,6 +72,9 @@ export class Supervisor {
|
|||||||
l4tVersion: await osRelease.getL4tVersion(),
|
l4tVersion: await osRelease.getL4tVersion(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
log.info('Starting firewall');
|
||||||
|
await firewall.initialised;
|
||||||
|
|
||||||
log.debug('Starting api binder');
|
log.debug('Starting api binder');
|
||||||
await this.apiBinder.initClient();
|
await this.apiBinder.initClient();
|
||||||
|
|
||||||
@ -86,11 +90,7 @@ export class Supervisor {
|
|||||||
await this.deviceState.init();
|
await this.deviceState.init();
|
||||||
|
|
||||||
log.info('Starting API server');
|
log.info('Starting API server');
|
||||||
this.api.listen(
|
this.api.listen(conf.listenPort, conf.apiTimeout);
|
||||||
constants.allowedInterfaces,
|
|
||||||
conf.listenPort,
|
|
||||||
conf.apiTimeout,
|
|
||||||
);
|
|
||||||
this.deviceState.on('shutdown', () => this.api.stop());
|
this.deviceState.on('shutdown', () => this.api.stop());
|
||||||
|
|
||||||
await this.apiBinder.start();
|
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.DATABASE_PATH_3 = './test/data/database3.sqlite';
|
||||||
process.env.LED_FILE = './test/data/led_file';
|
process.env.LED_FILE = './test/data/led_file';
|
||||||
|
|
||||||
|
import './lib/mocked-iptables';
|
||||||
|
|
||||||
import * as dbus from 'dbus';
|
import * as dbus from 'dbus';
|
||||||
import { DBusError, DBusInterface } from 'dbus';
|
import { DBusError, DBusInterface } from 'dbus';
|
||||||
import { stub } from 'sinon';
|
import { stub } from 'sinon';
|
||||||
|
@ -41,6 +41,7 @@ const testTarget1 = {
|
|||||||
name: 'aDevice',
|
name: 'aDevice',
|
||||||
config: {
|
config: {
|
||||||
HOST_CONFIG_gpu_mem: '256',
|
HOST_CONFIG_gpu_mem: '256',
|
||||||
|
HOST_FIREWALL_MODE: 'off',
|
||||||
SUPERVISOR_CONNECTIVITY_CHECK: 'true',
|
SUPERVISOR_CONNECTIVITY_CHECK: 'true',
|
||||||
SUPERVISOR_DELTA: 'false',
|
SUPERVISOR_DELTA: 'false',
|
||||||
SUPERVISOR_DELTA_APPLY_TIMEOUT: '0',
|
SUPERVISOR_DELTA_APPLY_TIMEOUT: '0',
|
||||||
@ -127,6 +128,7 @@ const testTargetWithDefaults2 = {
|
|||||||
name: 'aDeviceWithDifferentName',
|
name: 'aDeviceWithDifferentName',
|
||||||
config: {
|
config: {
|
||||||
HOST_CONFIG_gpu_mem: '512',
|
HOST_CONFIG_gpu_mem: '512',
|
||||||
|
HOST_FIREWALL_MODE: 'off',
|
||||||
SUPERVISOR_CONNECTIVITY_CHECK: 'true',
|
SUPERVISOR_CONNECTIVITY_CHECK: 'true',
|
||||||
SUPERVISOR_DELTA: 'false',
|
SUPERVISOR_DELTA: 'false',
|
||||||
SUPERVISOR_DELTA_APPLY_TIMEOUT: '0',
|
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 defaultConfigBackend = config.configJsonBackend;
|
||||||
const initModels = async (obj: Dictionary<any>, filename: string) => {
|
const initModels = async (obj: Dictionary<any>, filename: string) => {
|
||||||
await prepare();
|
await prepare();
|
||||||
config.removeAllListeners();
|
|
||||||
|
|
||||||
// @ts-expect-error setting read-only property
|
// @ts-expect-error setting read-only property
|
||||||
config.configJsonBackend = new ConfigJsonConfigBackend(schema, filename);
|
config.configJsonBackend = new ConfigJsonConfigBackend(schema, filename);
|
||||||
config.generateRequiredFields();
|
config.generateRequiredFields();
|
||||||
|
@ -191,6 +191,7 @@ describe('Device Backend Config', () => {
|
|||||||
it('returns default configuration values', () => {
|
it('returns default configuration values', () => {
|
||||||
const conf = deviceConfig.getDefaults();
|
const conf = deviceConfig.getDefaults();
|
||||||
return expect(conf).to.deep.equal({
|
return expect(conf).to.deep.equal({
|
||||||
|
HOST_FIREWALL_MODE: 'off',
|
||||||
SUPERVISOR_VPN_CONTROL: 'true',
|
SUPERVISOR_VPN_CONTROL: 'true',
|
||||||
SUPERVISOR_POLL_INTERVAL: '60000',
|
SUPERVISOR_POLL_INTERVAL: '60000',
|
||||||
SUPERVISOR_LOCAL_MODE: 'false',
|
SUPERVISOR_LOCAL_MODE: 'false',
|
||||||
|
@ -16,7 +16,6 @@ const mockedOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const VALID_SECRET = mockedAPI.STUBBED_VALUES.config.apiSecret;
|
const VALID_SECRET = mockedAPI.STUBBED_VALUES.config.apiSecret;
|
||||||
const ALLOWED_INTERFACES = ['lo']; // Only need loopback since this is for testing
|
|
||||||
|
|
||||||
describe('SupervisorAPI', () => {
|
describe('SupervisorAPI', () => {
|
||||||
let api: SupervisorAPI;
|
let api: SupervisorAPI;
|
||||||
@ -38,11 +37,7 @@ describe('SupervisorAPI', () => {
|
|||||||
images.getStatus = () => Promise.resolve([]);
|
images.getStatus = () => Promise.resolve([]);
|
||||||
|
|
||||||
// Start test API
|
// Start test API
|
||||||
return api.listen(
|
return api.listen(mockedOptions.listenPort, mockedOptions.timeout);
|
||||||
ALLOWED_INTERFACES,
|
|
||||||
mockedOptions.listenPort,
|
|
||||||
mockedOptions.timeout,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
after(async () => {
|
after(async () => {
|
||||||
@ -216,20 +211,12 @@ describe('SupervisorAPI', () => {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
Log.error.restore();
|
Log.error.restore();
|
||||||
// Resume API for other test suites
|
// Resume API for other test suites
|
||||||
return api.listen(
|
return api.listen(mockedOptions.listenPort, mockedOptions.timeout);
|
||||||
ALLOWED_INTERFACES,
|
|
||||||
mockedOptions.listenPort,
|
|
||||||
mockedOptions.timeout,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('logs successful start', async () => {
|
it('logs successful start', async () => {
|
||||||
// Start API
|
// Start API
|
||||||
await api.listen(
|
await api.listen(mockedOptions.listenPort, mockedOptions.timeout);
|
||||||
ALLOWED_INTERFACES,
|
|
||||||
mockedOptions.listenPort,
|
|
||||||
mockedOptions.timeout,
|
|
||||||
);
|
|
||||||
// Check if success start was logged
|
// Check if success start was logged
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
expect(Log.info.lastCall?.lastArg).to.equal(
|
expect(Log.info.lastCall?.lastArg).to.equal(
|
||||||
@ -239,11 +226,7 @@ describe('SupervisorAPI', () => {
|
|||||||
|
|
||||||
it('logs shutdown', async () => {
|
it('logs shutdown', async () => {
|
||||||
// Start API
|
// Start API
|
||||||
await api.listen(
|
await api.listen(mockedOptions.listenPort, mockedOptions.timeout);
|
||||||
ALLOWED_INTERFACES,
|
|
||||||
mockedOptions.listenPort,
|
|
||||||
mockedOptions.timeout,
|
|
||||||
);
|
|
||||||
// Stop API
|
// Stop API
|
||||||
await api.stop();
|
await api.stop();
|
||||||
// Check if stopped with info was logged
|
// Check if stopped with info was logged
|
||||||
@ -253,11 +236,7 @@ describe('SupervisorAPI', () => {
|
|||||||
|
|
||||||
it('logs errored shutdown', async () => {
|
it('logs errored shutdown', async () => {
|
||||||
// Start API
|
// Start API
|
||||||
await api.listen(
|
await api.listen(mockedOptions.listenPort, mockedOptions.timeout);
|
||||||
ALLOWED_INTERFACES,
|
|
||||||
mockedOptions.listenPort,
|
|
||||||
mockedOptions.timeout,
|
|
||||||
);
|
|
||||||
// Stop API with error
|
// Stop API with error
|
||||||
await api.stop({ errored: true });
|
await api.stop({ errored: true });
|
||||||
// Check if stopped with error was logged
|
// Check if stopped with error was logged
|
||||||
|
@ -10,7 +10,6 @@ const mockedOptions = {
|
|||||||
|
|
||||||
const VALID_SECRET = mockedAPI.STUBBED_VALUES.config.apiSecret;
|
const VALID_SECRET = mockedAPI.STUBBED_VALUES.config.apiSecret;
|
||||||
const INVALID_SECRET = 'bad_api_secret';
|
const INVALID_SECRET = 'bad_api_secret';
|
||||||
const ALLOWED_INTERFACES = ['lo']; // Only need loopback since this is for testing
|
|
||||||
|
|
||||||
describe('SupervisorAPI authentication', () => {
|
describe('SupervisorAPI authentication', () => {
|
||||||
let api: SupervisorAPI;
|
let api: SupervisorAPI;
|
||||||
@ -20,11 +19,7 @@ describe('SupervisorAPI authentication', () => {
|
|||||||
// Create test API
|
// Create test API
|
||||||
api = await mockedAPI.create();
|
api = await mockedAPI.create();
|
||||||
// Start test API
|
// Start test API
|
||||||
return api.listen(
|
return api.listen(mockedOptions.listenPort, mockedOptions.timeout);
|
||||||
ALLOWED_INTERFACES,
|
|
||||||
mockedOptions.listenPort,
|
|
||||||
mockedOptions.timeout,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
after(async () => {
|
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