Commit 6fb092c4 authored by Łukasz Nowak's avatar Łukasz Nowak

manager: Implement whitelistfirewall

whitelistfirewall manager is locking down partition access as configured in
partition's .slapos-whitelist-firewall file by using firewalld configuration.

The md5sum of last generated partition whitelist file is used to take
decision if given rule shall be updated.

The manager supports partition setup and destruction.

Note: User id is used with netfilter owner module, as currently the sourcing
      address is not enforced to be per partition.
parent 25e01390
Pipeline #14425 running with stage
in 0 seconds
...@@ -75,7 +75,8 @@ setup(name=name, ...@@ -75,7 +75,8 @@ setup(name=name,
'cachecontrol', 'cachecontrol',
'lockfile', 'lockfile',
'uritemplate', # used by hateoas navigator 'uritemplate', # used by hateoas navigator
'subprocess32; python_version<"3"' 'subprocess32; python_version<"3"',
'ipaddress; python_version<"3"', # used by whitelistfirewall
] + additional_install_requires, ] + additional_install_requires,
extras_require=extras_require, extras_require=extras_require,
tests_require=extras_require['test'], tests_require=extras_require['test'],
......
# coding: utf-8
# Copyright (C) 2021 Nexedi SA and Contributors.
# Łukasz Nowak <luke@nexedi.com>
#
# This program is free software: you can Use, Study, Modify and Redistribute
# it under the terms of the GNU General Public License version 3, or (at your
# option) any later version, as published by the Free Software Foundation.
#
# You can also Link and Combine this program with other software covered by
# the terms of any of the Free Software licenses or any of the Open Source
# Initiative approved licenses and Convey the resulting work. Corresponding
# source of such a combination shall include the source code for all other
# software used.
#
# This program is distributed WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
#
# See COPYING file for full licensing terms.
# See https://www.nexedi.com/licensing for rationale and options.
import hashlib
import ipaddress
import json
import logging
import os
import subprocess
from .interface import IManager
from zope.interface import implementer
logger = logging.getLogger(__name__)
# stolen from slapos/grid/slapgrid.py
class FPopen(subprocess.Popen):
def __init__(self, *args, **kwargs):
kwargs['stdin'] = subprocess.PIPE
kwargs['stderr'] = subprocess.STDOUT
kwargs.setdefault('stdout', subprocess.PIPE)
kwargs.setdefault('close_fds', True)
kwargs.setdefault('shell', False)
subprocess.Popen.__init__(self, *args, **kwargs)
self.stdin.flush()
self.stdin.close()
self.stdin = None
def fexecute(arg_list):
process = FPopen(arg_list, universal_newlines=True)
result, _ = process.communicate()
return process.returncode, result
@implementer(IManager)
class Manager(object):
whitelist_firewall_filename = '.slapos-whitelist-firewall'
whitelist_firewall_md5sum = '.slapos-whitelist-firewall.md5sum'
def __init__(self, config):
"""Manager needs to know config for its functioning.
"""
self.config = 'firewall' in config and config['firewall'] or None
def format(self, computer):
"""Method called at `slapos node format` phase.
:param computer: slapos.format.Computer, currently formatted computer
"""
def formatTearDown(self, computer):
"""Method called after `slapos node format` phase.
:param computer: slapos.format.Computer, formatted computer
"""
def software(self, software):
"""Method called at `slapos node software` phase.
:param software: slapos.grid.SlapObject.Software, currently processed
software
"""
def softwareTearDown(self, software):
"""Method called after `slapos node software` phase.
:param computer: slapos.grid.SlapObject.Software, processed software
"""
def instance(self, partition):
"""Method called at `slapos node instance` phase.
:param partition: slapos.grid.SlapObject.Partition, currently processed
partition
"""
def _fwCommunicate(self, arg_list):
return fexecute(
[self.config['firewall_cmd'], '--direct', '--permanent'] + arg_list
)[1]
def _reloadFirewalld(self):
return_code, output = fexecute([self.config['firewall_cmd'], '--reload'])
if return_code != 0:
raise ValueError('Problem while reloading firewalld: %s', output)
def _cleanUpPartitionFirewall(self, partition):
chain_name = '%s-whitelist' % (partition.partition_id,)
chain_cmd = ['ipv4', 'filter', chain_name]
user_id = partition.getUserGroupId()[0]
reload_firewall = False
if self._fwCommunicate(['--query-chain'] + chain_cmd).strip() == 'yes':
self._fwCommunicate(['--remove-rules', 'ipv4', 'filter', chain_name])
self._fwCommunicate(['--remove-chain'] + chain_cmd)
reload_firewall = True
rule_cmd = [
'ipv4', 'filter', 'OUTPUT', '0', '-m', 'owner', '--uid-owner',
str(user_id), '-j', chain_name]
if self._fwCommunicate(['--query-rule'] + rule_cmd).strip() == 'yes':
self._fwCommunicate(['--remove-rule'] + rule_cmd)
reload_firewall = True
if reload_firewall:
self._reloadFirewalld()
return reload_firewall
def instanceTearDown(self, partition):
"""Method called after `slapos node instance` phase.
:param partition: slapos.grid.SlapObject.Partition, processed partition
"""
if self.config is None:
logger.warning(
'[firewall] missing in the configuration, manager disabled.')
return
chain_name = '%s-whitelist' % (partition.partition_id,)
chain_cmd = ['ipv4', 'filter', chain_name]
user_id = partition.getUserGroupId()[0]
whitelist_firewall_md5sum_path = os.path.join(
partition.instance_path, self.whitelist_firewall_md5sum)
whitelist_firewall_path = os.path.join(
partition.instance_path, self.whitelist_firewall_filename)
if not os.path.exists(whitelist_firewall_path):
if self._cleanUpPartitionFirewall(partition):
logger.info(
'File %s does not exists, removed configuration for partition %s.',
whitelist_firewall_path, partition.partition_id)
if os.path.exists(whitelist_firewall_md5sum_path):
os.unlink(whitelist_firewall_md5sum_path)
return
with open(whitelist_firewall_path, 'rb') as fh:
whitelist_firewall_path_md5sum = hashlib.md5(fh.read()).hexdigest()
with open(whitelist_firewall_path) as f:
try:
json_list = json.load(f)
assert isinstance(json_list, list)
ip_list = []
for ip in json_list:
try:
ip_address = ipaddress.ip_address(ip)
if ip_address.version == 4:
ip_list.append(str(ip_address.exploded))
else:
logger.warning('Entry %r is not an IPv4', ip)
except Exception:
logger.warning('Entry %r is not a real IP', ip)
except Exception:
logger.warning(
'Bad whitelist firewall config %s', whitelist_firewall_path,
exc_info=True)
return
try:
with open(whitelist_firewall_md5sum_path, 'rb') as fh:
previous_md5sum = fh.read().strip()
except Exception:
# whatever happened, it means that md5sum became unreadable, so
# simply reset previous md5sum
previous_md5sum = b''
logger.info('Configuring partition %s.', partition.partition_id)
chain_added = False
if self._fwCommunicate(['--query-chain'] + chain_cmd).strip() != 'yes':
self._fwCommunicate(['--add-chain'] + chain_cmd)
chain_added = True
if chain_added or \
whitelist_firewall_path_md5sum != previous_md5sum.decode('utf-8'):
with open(whitelist_firewall_md5sum_path, 'wb') as fh:
# enforce re-add rules on next run, if any problem would be
# encountered
fh.write(b'')
self._fwCommunicate(['--remove-rules', 'ipv4', 'filter', chain_name])
for ip in ip_list:
# whitelist what is expected
self._fwCommunicate([
'--add-rule', 'ipv4', 'filter', chain_name, '0', '-d', ip, '-j',
'ACCEPT'])
# drop everything else
self._fwCommunicate([
'--add-rule', 'ipv4', 'filter', chain_name, '0', '-j', 'REJECT'])
# configure the rule for the user to whitelist the partition access
rule_cmd = [
'ipv4', 'filter', 'OUTPUT', '0', '-m', 'owner', '--uid-owner',
str(user_id), '-j', chain_name]
if self._fwCommunicate(['--query-rule'] + rule_cmd).strip() != 'yes':
self._fwCommunicate(['--add-rule'] + rule_cmd)
self._reloadFirewalld()
with open(whitelist_firewall_md5sum_path, 'wb') as fh:
# "commit" changes to the md5sum file
fh.write(whitelist_firewall_path_md5sum.encode())
logger.info('Updated rules for partition %s.', partition.partition_id)
else:
logger.debug(
'Skipped up-to-date rules for partition %s.', partition.partition_id)
def report(self, partition):
"""Method called at `slapos node report` phase.
:param partition: slapos.grid.SlapObject.Partition, currently processed
partition
"""
if self._cleanUpPartitionFirewall(partition):
logger.info(
'Cleaned up firewall for partition %s.', partition.partition_id)
whitelist_firewall_md5sum_path = os.path.join(
partition.instance_path, self.whitelist_firewall_md5sum)
if os.path.exists(whitelist_firewall_md5sum_path):
os.unlink(whitelist_firewall_md5sum_path)
...@@ -44,6 +44,7 @@ from six.moves.urllib import parse ...@@ -44,6 +44,7 @@ from six.moves.urllib import parse
import json import json
import re import re
import grp import grp
import hashlib
import mock import mock
from mock import patch from mock import patch
...@@ -3380,6 +3381,251 @@ class TestSlapgridWithDevPermManagerDevPermAllowLsblk(TestSlapgridWithDevPermLsb ...@@ -3380,6 +3381,251 @@ class TestSlapgridWithDevPermManagerDevPermAllowLsblk(TestSlapgridWithDevPermLsb
] ]
class TestSlapgridWithWhitelistfirewall(MasterMixin, unittest.TestCase):
config = {
'manager_list': 'whitelistfirewall',
'firewall':{
'firewall_cmd': 'firewall_cmd',
}
}
def setUp(self):
MasterMixin.setUp(self)
self.uid_owner = str(os.stat(os.environ['HOME']).st_uid)
manager_list = slapmanager.from_config(self.config)
self.grid._manager_list = manager_list
self.computer = ComputerForTest(self.software_root, self.instance_root)
self.partition = self.computer.instance_list[0]
self.whitelist_firewall_filename = os.path.join(
self.partition.partition_path,
slapmanager.whitelistfirewall.Manager.whitelist_firewall_filename)
self.whitelist_firewall_md5sum = os.path.join(
self.partition.partition_path,
slapmanager.whitelistfirewall.Manager.whitelist_firewall_md5sum)
self.fexecute_call_list = []
self.fexecute_side_effect = {}
self.fexecute_returncode = 0
self.fexecute_result = ''
self.patcher_list = [
patch.object(slapmanager.whitelistfirewall, 'fexecute', new=self.fexecute)
]
[q.start() for q in self.patcher_list]
def fexecute(self, arg_list):
self.fexecute_call_list.append(arg_list)
return self.fexecute_side_effect.get(str(arg_list), (0, ''))
def tearDown(self):
[q.stop() for q in self.patcher_list]
def _mock_requests(self):
return httmock.HTTMock(self.computer.request_handler)
def test(self):
with self._mock_requests():
with open(self.whitelist_firewall_filename, 'w+') as f:
json.dump(['127.0.0.1'], f)
self.partition.requested_state = 'started'
self.partition.software.setBuildout(WRAPPER_CONTENT)
self.assertEqual(self.grid.processComputerPartitionList(), slapgrid.SLAPGRID_SUCCESS)
query_chain = ['firewall_cmd', '--direct', '--permanent', '--query-chain', 'ipv4', 'filter', '0-whitelist']
query_owner_rule = ['firewall_cmd', '--direct', '--permanent', '--query-rule', 'ipv4', 'filter', 'OUTPUT', '0', '-m', 'owner', '--uid-owner', self.uid_owner, '-j', '0-whitelist']
self.assertEqual(
self.fexecute_call_list,
[
query_chain,
['firewall_cmd', '--direct', '--permanent', '--add-chain', 'ipv4', 'filter', '0-whitelist'],
['firewall_cmd', '--direct', '--permanent', '--remove-rules', 'ipv4', 'filter', '0-whitelist'],
['firewall_cmd', '--direct', '--permanent', '--add-rule', 'ipv4', 'filter', '0-whitelist', '0', '-d', u'127.0.0.1', '-j', 'ACCEPT'],
['firewall_cmd', '--direct', '--permanent', '--add-rule', 'ipv4', 'filter', '0-whitelist', '0', '-j', 'REJECT'],
query_owner_rule,
['firewall_cmd', '--direct', '--permanent', '--add-rule', 'ipv4', 'filter', 'OUTPUT', '0', '-m', 'owner', '--uid-owner', self.uid_owner, '-j', '0-whitelist'],
['firewall_cmd', '--reload']
]
)
with open(self.whitelist_firewall_filename, 'rb') as fh:
expected_md5sum = hashlib.md5(fh.read().strip()).hexdigest()
try:
with open(self.whitelist_firewall_md5sum, 'rb') as fh:
got_md5sum = fh.read().strip().decode("utf-8")
except Exception:
got_md5sum = None
self.assertEqual(expected_md5sum, got_md5sum)
# check no-op behaviour
self.fexecute_call_list = []
self.fexecute_side_effect[str(query_chain)] = (0, 'yes')
self.fexecute_side_effect[str(query_owner_rule)] = (0, 'yes')
self.partition.requested_state = 'started'
self.partition.software.setBuildout(WRAPPER_CONTENT)
self.assertEqual(self.grid.processComputerPartitionList(), slapgrid.SLAPGRID_SUCCESS)
self.assertEqual(
self.fexecute_call_list,
[
query_chain
]
)
# check update
self.fexecute_call_list = []
self.fexecute_side_effect[str(query_chain)] = (0, 'yes')
self.fexecute_side_effect[str(query_owner_rule)] = (0, 'yes')
with open(self.whitelist_firewall_filename, 'w+') as f:
json.dump(['127.0.0.1', '127.0.0.2'], f)
self.partition.requested_state = 'started'
self.partition.software.setBuildout(WRAPPER_CONTENT)
self.assertEqual(self.grid.processComputerPartitionList(), slapgrid.SLAPGRID_SUCCESS)
self.assertEqual(
self.fexecute_call_list,
[
query_chain,
['firewall_cmd', '--direct', '--permanent', '--remove-rules', 'ipv4', 'filter', '0-whitelist'],
['firewall_cmd', '--direct', '--permanent', '--add-rule', 'ipv4', 'filter', '0-whitelist', '0', '-d', '127.0.0.1', '-j', 'ACCEPT'],
['firewall_cmd', '--direct', '--permanent', '--add-rule', 'ipv4', 'filter', '0-whitelist', '0', '-d', '127.0.0.2', '-j', 'ACCEPT'],
['firewall_cmd', '--direct', '--permanent', '--add-rule', 'ipv4', 'filter', '0-whitelist', '0', '-j', 'REJECT'],
query_owner_rule,
['firewall_cmd', '--reload']]
)
# check behaviour after removing the configuration file
os.unlink(self.whitelist_firewall_filename)
# reset previous calls
self.fexecute_call_list = []
# setup side effects
self.fexecute_side_effect[str(query_chain)] = (0, 'yes')
self.fexecute_side_effect[str(query_owner_rule)] = (0, 'yes')
self.partition.requested_state = 'started'
self.partition.software.setBuildout(WRAPPER_CONTENT)
self.assertEqual(self.grid.processComputerPartitionList(), slapgrid.SLAPGRID_SUCCESS)
self.assertEqual(
self.fexecute_call_list,
[
query_chain,
['firewall_cmd', '--direct', '--permanent', '--remove-rules', 'ipv4', 'filter', '0-whitelist'],
['firewall_cmd', '--direct', '--permanent', '--remove-chain', 'ipv4', 'filter', '0-whitelist'],
query_owner_rule,
['firewall_cmd', '--direct', '--permanent', '--remove-rule', 'ipv4', 'filter', 'OUTPUT', '0', '-m', 'owner', '--uid-owner', self.uid_owner, '-j', '0-whitelist'],
['firewall_cmd', '--reload']
]
)
self.assertFalse(os.path.exists(self.whitelist_firewall_md5sum))
def test_damaged(self):
with self._mock_requests():
with open(self.whitelist_firewall_filename, 'w+') as f:
f.write('nothing')
self.partition.requested_state = 'started'
self.partition.software.setBuildout(WRAPPER_CONTENT)
self.assertEqual(self.grid.processComputerPartitionList(), slapgrid.SLAPGRID_SUCCESS)
self.assertEqual(self.fexecute_call_list, [])
self.assertFalse(os.path.exists(self.whitelist_firewall_md5sum))
def test_not_list(self):
with self._mock_requests():
with open(self.whitelist_firewall_filename, 'w+') as f:
json.dump({'127.0.0.1': '127.0.0.2'}, f)
self.partition.requested_state = 'started'
self.partition.software.setBuildout(WRAPPER_CONTENT)
self.assertEqual(self.grid.processComputerPartitionList(), slapgrid.SLAPGRID_SUCCESS)
self.assertEqual(self.fexecute_call_list, [])
self.assertFalse(os.path.exists(self.whitelist_firewall_md5sum))
def test_with_bad_entry(self):
with self._mock_requests():
with open(self.whitelist_firewall_filename, 'w+') as f:
json.dump(['127.0.0.1', 'superpaczynka127.0.0.1', '::1'], f)
self.partition.requested_state = 'started'
self.partition.software.setBuildout(WRAPPER_CONTENT)
self.assertEqual(self.grid.processComputerPartitionList(), slapgrid.SLAPGRID_SUCCESS)
self.assertEqual(
self.fexecute_call_list,
[
['firewall_cmd', '--direct', '--permanent', '--query-chain', 'ipv4', 'filter', '0-whitelist'],
['firewall_cmd', '--direct', '--permanent', '--add-chain', 'ipv4', 'filter', '0-whitelist'],
['firewall_cmd', '--direct', '--permanent', '--remove-rules', 'ipv4', 'filter', '0-whitelist'],
['firewall_cmd', '--direct', '--permanent', '--add-rule', 'ipv4', 'filter', '0-whitelist', '0', '-d', u'127.0.0.1', '-j', 'ACCEPT'],
['firewall_cmd', '--direct', '--permanent', '--add-rule', 'ipv4', 'filter', '0-whitelist', '0', '-j', 'REJECT'],
['firewall_cmd', '--direct', '--permanent', '--query-rule', 'ipv4', 'filter', 'OUTPUT', '0', '-m', 'owner', '--uid-owner', self.uid_owner, '-j', '0-whitelist'],
['firewall_cmd', '--direct', '--permanent', '--add-rule', 'ipv4', 'filter', 'OUTPUT', '0', '-m', 'owner', '--uid-owner', self.uid_owner, '-j', '0-whitelist'],
['firewall_cmd', '--reload']]
)
with open(self.whitelist_firewall_filename, 'rb') as fh:
expected_md5sum = hashlib.md5(fh.read().strip()).hexdigest()
try:
with open(self.whitelist_firewall_md5sum, 'rb') as fh:
got_md5sum = fh.read().strip().decode('utf-8')
except Exception:
got_md5sum = None
self.assertEqual(expected_md5sum, got_md5sum)
def test_cleanup_on_destroy(self):
with self._mock_requests():
with open(self.whitelist_firewall_md5sum, 'w+') as fh:
fh.write('')
query_chain = ['firewall_cmd', '--direct', '--permanent', '--query-chain', 'ipv4', 'filter', '0-whitelist']
query_owner_rule = ['firewall_cmd', '--direct', '--permanent', '--query-rule', 'ipv4', 'filter', 'OUTPUT', '0', '-m', 'owner', '--uid-owner', self.uid_owner, '-j', '0-whitelist']
# setup side effects
self.fexecute_side_effect[str(query_chain)] = (0, 'yes')
self.fexecute_side_effect[str(query_owner_rule)] = (0, 'yes')
self.partition.requested_state = 'destroyed'
self.partition.software.setBuildout(WRAPPER_CONTENT)
self.assertEqual(self.grid.agregateAndSendUsage(), slapgrid.SLAPGRID_SUCCESS)
self.assertEqual(
self.fexecute_call_list,
[
query_chain,
['firewall_cmd', '--direct', '--permanent', '--remove-rules', 'ipv4', 'filter', '0-whitelist'],
['firewall_cmd', '--direct', '--permanent', '--remove-chain', 'ipv4', 'filter', '0-whitelist'],
query_owner_rule,
['firewall_cmd', '--direct', '--permanent', '--remove-rule', 'ipv4', 'filter', 'OUTPUT', '0', '-m', 'owner', '--uid-owner', self.uid_owner, '-j', '0-whitelist'],
['firewall_cmd', '--reload']
]
)
self.assertFalse(os.path.exists(self.whitelist_firewall_md5sum))
# check cleanup no-op
self.fexecute_call_list = []
# setup side effects
self.fexecute_side_effect[str(query_chain)] = (0, 'no')
self.fexecute_side_effect[str(query_owner_rule)] = (0, 'no')
self.partition.requested_state = 'destroyed'
self.partition.software.setBuildout(WRAPPER_CONTENT)
self.assertEqual(self.grid.agregateAndSendUsage(), slapgrid.SLAPGRID_SUCCESS)
self.assertEqual(
self.fexecute_call_list,
[
query_chain,
query_owner_rule
]
)
class TestSlapgridManagerLifecycle(MasterMixin, unittest.TestCase): class TestSlapgridManagerLifecycle(MasterMixin, unittest.TestCase):
def setUp(self): def setUp(self):
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment