Browse Source

Refactoring the firewall

Adopting the PXE filter interface/driver concept

Related-Bug: 1665666
Change-Id: If83db978080b9c4e5d51ba50bbe8ed26e29abe83
changes/31/471831/33
dparalen 5 years ago
parent
commit
7b27585463
  1. 2
      CONTRIBUTING.rst
  2. 7
      doc/source/install/index.rst
  3. 6
      doc/source/user/usage.rst
  4. 2
      doc/source/user/workflow.rst
  5. 22
      example.conf
  6. 35
      ironic_inspector/conf.py
  7. 257
      ironic_inspector/firewall.py
  8. 12
      ironic_inspector/introspect.py
  9. 4
      ironic_inspector/process.py
  10. 232
      ironic_inspector/pxe_filter/iptables.py
  11. 4
      ironic_inspector/test/functional.py
  12. 444
      ironic_inspector/test/unit/test_firewall.py
  13. 87
      ironic_inspector/test/unit/test_introspect.py
  14. 356
      ironic_inspector/test/unit/test_iptables.py
  15. 4
      ironic_inspector/test/unit/test_process.py
  16. 2
      ironic_inspector/test/unit/test_pxe_filter.py
  17. 45
      ironic_inspector/test/unit/test_wsgi_service.py
  18. 16
      ironic_inspector/wsgi_service.py
  19. 23
      releasenotes/notes/firewall-refactoring-17e8ad764f2cde8d.yaml
  20. 1
      setup.cfg

2
CONTRIBUTING.rst

@ -320,8 +320,6 @@ the database::
Implementing PXE Filter Drivers
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. note:: It is not possible yet to use PXE filter drivers.
Background
----------

7
doc/source/install/index.rst

@ -71,7 +71,7 @@ Fill in these minimum configuration values:
* ``connection`` in the ``database`` section - SQLAlchemy connection string
for the database.
* ``dnsmasq_interface`` in the ``firewall`` section - interface on which
* ``dnsmasq_interface`` in the ``iptables`` section - interface on which
``dnsmasq`` (or another DHCP service) listens for PXE boot requests
(defaults to ``br-ctlplane`` which is a sane default for **tripleo**-based
installations but is unlikely to work for other cases).
@ -93,7 +93,10 @@ Here is an example *inspector.conf* (adapted from a gate run)::
[database]
connection = mysql+pymysql://root:<PASSWORD>@127.0.0.1/ironic_inspector?charset=utf8
[firewall]
[pxe_filter]
driver=iptables
[iptables]
dnsmasq_interface = br-ctlplane
[ironic]

6
doc/source/user/usage.rst

@ -371,12 +371,12 @@ InfiniBand network interfaces. A recent (Ocata or newer) IPA image is required
for that to work. When an InfiniBand network interface is discovered, the
**Ironic Inspector** adds a ``client-id`` attribute to the ``extra`` attribute
in the ironic port. The **Ironic Inspector** should be configured with
``firewall.ethoib_interfaces`` to indicate the Ethernet Over InfiniBand (EoIB)
``iptables.ethoib_interfaces`` to indicate the Ethernet Over InfiniBand (EoIB)
which are used for physical access access to the DHCP network.
For example if **Ironic Inspector** DHCP server is using ``br-inspector`` and
the ``br-inspector`` has EoIB port e.g. ``eth0``,
the ``firewall.ethoib_interfaces`` should be set to ``eth0``.
The ``firewall.ethoib_interfaces`` allows to map the baremetal GUID to it's
the ``iptables.ethoib_interfaces`` should be set to ``eth0``.
The ``iptables.ethoib_interfaces`` allows to map the baremetal GUID to it's
EoIB MAC based on the neighs files. This is needed for blocking DHCP traffic
of the nodes (MACs) which are not part of the introspection.

2
doc/source/user/workflow.rst

@ -18,7 +18,7 @@ Usual hardware introspection flow is as follows:
* On receiving node UUID **ironic-inspector**:
* validates node power credentials, current power and provisioning states,
* allows firewall access to PXE boot service for the nodes,
* allows access to PXE boot service for the nodes,
* issues reboot command for the nodes, so that they boot the ramdisk.
* The ramdisk collects the required information and posts it back to

22
example.conf

@ -340,23 +340,25 @@
#enroll_node_driver = fake
[firewall]
[iptables]
#
# From ironic_inspector
#
# Whether to manage firewall rules for PXE port. (boolean value)
# DEPRECATED: Whether to manage firewall rules for PXE port. This
# configuration option was deprecated in favor of the ``driver``
# option in the ``pxe_filter`` section. Please, use the ``noop``
# filter driver to disable the firewall filtering or the ``iptables``
# filter driver to enable it. (boolean value)
# This option is deprecated for removal.
# Its value may be silently ignored in the future.
#manage_firewall = true
# Interface on which dnsmasq listens, the default is for VM's. (string
# value)
#dnsmasq_interface = br-ctlplane
# Amount of time in seconds, after which repeat periodic update of
# firewall. (integer value)
#firewall_update_period = 15
# iptables chain name to use. (string value)
#firewall_chain = ironic-inspector
@ -761,13 +763,13 @@
# From ironic_inspector
#
# PXE boot filter driver to use, such as iptables. This option has no
# effect yet. (string value)
#driver = noop
# PXE boot filter driver to use, such as iptables (string value)
#driver = iptables
# Amount of time in seconds, after which repeat periodic update of the
# filter. This option has no effect yet. (integer value)
# filter. (integer value)
# Minimum value: 0
# Deprecated group/name - [firewall]/firewall_update_period
#sync_period = 15

35
ironic_inspector/conf.py

@ -28,22 +28,30 @@ VALID_KEEP_PORTS_VALUES = ('all', 'present', 'added')
VALID_STORE_DATA_VALUES = ('none', 'swift')
FIREWALL_OPTS = [
IPTABLES_OPTS = [
cfg.BoolOpt('manage_firewall',
default=True,
help=_('Whether to manage firewall rules for PXE port.')),
# NOTE(milan) this filter driver will be replaced by
# a dnsmasq filter driver
deprecated_for_removal=True,
deprecated_group='firewall',
help=_('Whether to manage firewall rules for PXE port. '
'This configuration option was deprecated in favor of '
'the ``driver`` option in the ``pxe_filter`` section. '
'Please, use the ``noop`` filter driver to disable the '
'firewall filtering or the ``iptables`` filter driver '
'to enable it.')),
cfg.StrOpt('dnsmasq_interface',
default='br-ctlplane',
deprecated_group='firewall',
help=_('Interface on which dnsmasq listens, the default is for '
'VM\'s.')),
cfg.IntOpt('firewall_update_period',
default=15,
help=_('Amount of time in seconds, after which repeat periodic '
'update of firewall.')),
cfg.StrOpt('firewall_chain',
default='ironic-inspector',
deprecated_group='firewall',
help=_('iptables chain name to use.')),
cfg.ListOpt('ethoib_interfaces',
deprecated_group='firewall',
default=[],
help=_('List of Etherent Over InfiniBand interfaces '
'on the Inspector host which are used for physical '
@ -190,17 +198,20 @@ SERVICE_OPTS = [
help=_('Limit the number of elements an API list-call returns'))
]
PXE_FILTER_OPTS = [
cfg.StrOpt('driver', default='noop',
help=_('PXE boot filter driver to use, such as iptables. '
'This option has no effect yet.')),
cfg.StrOpt('driver', default='iptables',
help=_('PXE boot filter driver to use, such as iptables')),
cfg.IntOpt('sync_period', default=15, min=0,
deprecated_name='firewall_update_period',
deprecated_group='firewall',
help=_('Amount of time in seconds, after which repeat periodic '
'update of the filter. This option has no effect yet.')),
'update of the filter.')),
]
cfg.CONF.register_opts(SERVICE_OPTS)
cfg.CONF.register_opts(FIREWALL_OPTS, group='firewall')
cfg.CONF.register_opts(IPTABLES_OPTS, group='iptables')
cfg.CONF.register_opts(PROCESSING_OPTS, group='processing')
cfg.CONF.register_opts(PXE_FILTER_OPTS, 'pxe_filter')
@ -208,7 +219,7 @@ cfg.CONF.register_opts(PXE_FILTER_OPTS, 'pxe_filter')
def list_opts():
return [
('', SERVICE_OPTS),
('firewall', FIREWALL_OPTS),
('iptables', IPTABLES_OPTS),
('processing', PROCESSING_OPTS),
('pxe_filter', PXE_FILTER_OPTS),
]

257
ironic_inspector/firewall.py

@ -1,257 +0,0 @@
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import contextlib
import os
import re
from eventlet.green import subprocess
from eventlet import semaphore
from oslo_config import cfg
from oslo_log import log
from ironic_inspector.common import ironic as ir_utils
from ironic_inspector import node_cache
CONF = cfg.CONF
LOG = log.getLogger("ironic_inspector.firewall")
NEW_CHAIN = None
CHAIN = None
INTERFACE = None
LOCK = semaphore.BoundedSemaphore()
BASE_COMMAND = None
BLACKLIST_CACHE = None
ENABLED = True
EMAC_REGEX = 'EMAC=([0-9a-f]{2}(:[0-9a-f]{2}){5}) IMAC=.*'
def _iptables(*args, **kwargs):
# NOTE(dtantsur): -w flag makes it wait for xtables lock
cmd = BASE_COMMAND + args
ignore = kwargs.pop('ignore', False)
LOG.debug('Running iptables %s', args)
kwargs['stderr'] = subprocess.STDOUT
try:
subprocess.check_output(cmd, **kwargs)
except subprocess.CalledProcessError as exc:
output = exc.output.replace('\n', '. ')
if ignore:
LOG.debug('Ignoring failed iptables %(args)s: %(output)s',
{'args': args, 'output': output})
else:
LOG.error('iptables %(iptables)s failed: %(exc)s',
{'iptables': args, 'exc': output})
raise
def init():
"""Initialize firewall management.
Must be called one on start-up.
"""
if not CONF.firewall.manage_firewall:
return
global INTERFACE, CHAIN, NEW_CHAIN, BASE_COMMAND, BLACKLIST_CACHE
BLACKLIST_CACHE = None
INTERFACE = CONF.firewall.dnsmasq_interface
CHAIN = CONF.firewall.firewall_chain
NEW_CHAIN = CHAIN + '_temp'
BASE_COMMAND = ('sudo', 'ironic-inspector-rootwrap',
CONF.rootwrap_config, 'iptables',)
# -w flag makes iptables wait for xtables lock, but it's not supported
# everywhere yet
try:
with open(os.devnull, 'wb') as null:
subprocess.check_call(BASE_COMMAND + ('-w', '-h'),
stderr=null, stdout=null)
except subprocess.CalledProcessError:
LOG.warning('iptables does not support -w flag, please update '
'it to at least version 1.4.21')
else:
BASE_COMMAND += ('-w',)
_clean_up(CHAIN)
# Not really needed, but helps to validate that we have access to iptables
_iptables('-N', CHAIN)
def _clean_up(chain):
_iptables('-D', 'INPUT', '-i', INTERFACE, '-p', 'udp',
'--dport', '67', '-j', chain,
ignore=True)
_iptables('-F', chain, ignore=True)
_iptables('-X', chain, ignore=True)
def clean_up():
"""Clean up everything before exiting."""
if not CONF.firewall.manage_firewall:
return
_clean_up(CHAIN)
_clean_up(NEW_CHAIN)
def _should_enable_dhcp():
"""Check whether we should enable DHCP at all.
We won't even open our DHCP if no nodes are on introspection and
node_not_found_hook is not set.
"""
return (node_cache.introspection_active() or
CONF.processing.node_not_found_hook)
@contextlib.contextmanager
def _temporary_chain(chain, main_chain):
"""Context manager to operate on a temporary chain."""
# Clean up a bit to account for possible troubles on previous run
_clean_up(chain)
_iptables('-N', chain)
yield
# Swap chains
_iptables('-I', 'INPUT', '-i', INTERFACE, '-p', 'udp',
'--dport', '67', '-j', chain)
_iptables('-D', 'INPUT', '-i', INTERFACE, '-p', 'udp',
'--dport', '67', '-j', main_chain,
ignore=True)
_iptables('-F', main_chain, ignore=True)
_iptables('-X', main_chain, ignore=True)
_iptables('-E', chain, main_chain)
def _disable_dhcp():
"""Disable DHCP completely."""
global ENABLED, BLACKLIST_CACHE
if not ENABLED:
LOG.debug('DHCP is already disabled, not updating')
return
LOG.debug('No nodes on introspection and node_not_found_hook is '
'not set - disabling DHCP')
BLACKLIST_CACHE = None
with _temporary_chain(NEW_CHAIN, CHAIN):
# Blacklist everything
_iptables('-A', NEW_CHAIN, '-j', 'REJECT')
ENABLED = False
def update_filters(ironic=None):
"""Update firewall filter rules for introspection.
Gives access to PXE boot port for any machine, except for those,
whose MAC is registered in Ironic and is not on introspection right now.
This function is called from both introspection initialization code and
from periodic task. This function is supposed to be resistant to unexpected
iptables state.
``init()`` function must be called once before any call to this function.
This function is using ``eventlet`` semaphore to serialize access from
different green threads.
Does nothing, if firewall management is disabled in configuration.
:param ironic: Ironic client instance, optional.
"""
global BLACKLIST_CACHE, ENABLED
if not CONF.firewall.manage_firewall:
return
assert INTERFACE is not None
ironic = ir_utils.get_client() if ironic is None else ironic
with LOCK:
if not _should_enable_dhcp():
_disable_dhcp()
return
ports_active = ironic.port.list(limit=0, fields=['address', 'extra'])
macs_active = set(p.address for p in ports_active)
to_blacklist = macs_active - node_cache.active_macs()
ib_mac_mapping = (
_ib_mac_to_rmac_mapping(to_blacklist, ports_active))
if (BLACKLIST_CACHE is not None and
to_blacklist == BLACKLIST_CACHE and not ib_mac_mapping):
LOG.debug('Not updating iptables - no changes in MAC list %s',
to_blacklist)
return
LOG.debug('Blacklisting active MAC\'s %s', to_blacklist)
# Force update on the next iteration if this attempt fails
BLACKLIST_CACHE = None
with _temporary_chain(NEW_CHAIN, CHAIN):
# - Blacklist active macs, so that nova can boot them
for mac in to_blacklist:
mac = ib_mac_mapping.get(mac) or mac
_iptables('-A', NEW_CHAIN, '-m', 'mac',
'--mac-source', mac, '-j', 'DROP')
# - Whitelist everything else
_iptables('-A', NEW_CHAIN, '-j', 'ACCEPT')
# Cache result of successful iptables update
ENABLED = True
BLACKLIST_CACHE = to_blacklist
def _ib_mac_to_rmac_mapping(blacklist_macs, ports_active):
"""Mapping between host InfiniBand MAC to EthernetOverInfiniBand MAC
On InfiniBand deployment we need to map between the baremetal host
InfiniBand MAC to the EoIB MAC. The EoIB MAC addresses are learned
automatically by the EoIB interfaces and those MACs are recorded
to the /sys/class/net/<ethoib_interface>/eth/neighs file.
The InfiniBand GUID is taken from the ironic port client-id extra
attribute. The InfiniBand GUID is the last 8 bytes of the client-id.
The file format allows to map the GUID to EoIB MAC. The firewall
rules based on those MACs get applied to the dnsmasq_interface by the
update_filters function.
:param blacklist_macs: List of InfiniBand baremetal hosts macs to
blacklist.
:param ports_active: list of active ironic ports
:return: baremetal InfiniBand to remote mac on ironic node mapping
"""
ethoib_interfaces = CONF.firewall.ethoib_interfaces
ib_mac_to_remote_mac = {}
for interface in ethoib_interfaces:
neighs_file = (
os.path.join('/sys/class/net', interface, 'eth/neighs'))
try:
with open(neighs_file, 'r') as fd:
data = fd.read()
except IOError:
LOG.error('Interface %s is not Ethernet Over InfiniBand; '
'Skipping ...', interface)
continue
for port in ports_active:
if port.address in blacklist_macs:
client_id = port.extra.get('client-id')
if client_id:
# Note(moshele): The last 8 bytes in the client-id is
# the baremetal node InfiniBand GUID
guid = client_id[-23:]
p = re.compile(EMAC_REGEX + guid)
match = p.search(data)
if match:
ib_mac_to_remote_mac[port.address] = match.group(1)
return ib_mac_to_remote_mac

12
ironic_inspector/introspect.py

@ -20,9 +20,9 @@ from oslo_config import cfg
from ironic_inspector.common.i18n import _
from ironic_inspector.common import ironic as ir_utils
from ironic_inspector import firewall
from ironic_inspector import introspection_state as istate
from ironic_inspector import node_cache
from ironic_inspector.pxe_filter import base as pxe_filter
from ironic_inspector import utils
CONF = cfg.CONF
@ -97,9 +97,9 @@ def _background_introspect_locked(node_info, ironic):
macs = list(node_info.ports())
if macs:
node_info.add_attribute(node_cache.MACS_ATTRIBUTE, macs)
LOG.info('Whitelisting MAC\'s %s on the firewall', macs,
LOG.info('Whitelisting MAC\'s %s for a PXE boot', macs,
node_info=node_info)
firewall.update_filters(ironic)
pxe_filter.driver().sync(ironic)
attrs = node_info.attributes
if CONF.processing.node_not_found_hook is None and not attrs:
@ -173,10 +173,10 @@ def _abort(node_info, ironic):
# block this node from PXE Booting the introspection image
try:
firewall.update_filters(ironic)
pxe_filter.driver().sync(ironic)
except Exception as exc:
# Note(mkovacik): this will be retried in firewall update
# Note(mkovacik): this will be retried in the PXE filter sync
# periodic task; we continue aborting
LOG.warning('Failed to update firewall filters: %s', exc,
LOG.warning('Failed to sync the PXE filter: %s', exc,
node_info=node_info)
LOG.info('Introspection aborted', node_info=node_info)

4
ironic_inspector/process.py

@ -26,10 +26,10 @@ from oslo_utils import timeutils
from ironic_inspector.common.i18n import _
from ironic_inspector.common import ironic as ir_utils
from ironic_inspector.common import swift
from ironic_inspector import firewall
from ironic_inspector import introspection_state as istate
from ironic_inspector import node_cache
from ironic_inspector.plugins import base as plugins_base
from ironic_inspector.pxe_filter import base as pxe_filter
from ironic_inspector import rules
from ironic_inspector import utils
@ -269,7 +269,7 @@ def _process_node(node_info, node, introspection_data):
_store_data(node_info, introspection_data)
ironic = ir_utils.get_client()
firewall.update_filters(ironic)
pxe_filter.driver().sync(ironic)
node_info.invalidate_cache()
rules.apply(node_info, introspection_data)

232
ironic_inspector/pxe_filter/iptables.py

@ -0,0 +1,232 @@
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import contextlib
import os
import re
from eventlet.green import subprocess
from oslo_config import cfg
from oslo_log import log
from ironic_inspector import node_cache
from ironic_inspector.pxe_filter import base as pxe_filter
CONF = cfg.CONF
LOG = log.getLogger(__name__)
_EMAC_REGEX = 'EMAC=([0-9a-f]{2}(:[0-9a-f]{2}){5}) IMAC=.*'
def _should_enable_dhcp():
"""Check whether we should enable DHCP at all.
We won't even open our DHCP if no nodes are on introspection and
node_not_found_hook is not set.
"""
return (node_cache.introspection_active() or
CONF.processing.node_not_found_hook is not None)
class IptablesFilter(pxe_filter.BaseFilter):
"""A PXE boot filtering interface implementation."""
def __init__(self):
super(IptablesFilter, self).__init__()
self.blacklist_cache = None
self.enabled = True
self.interface = CONF.iptables.dnsmasq_interface
self.chain = CONF.iptables.firewall_chain
self.new_chain = self.chain + '_temp'
self.base_command = ('sudo', 'ironic-inspector-rootwrap',
CONF.rootwrap_config, 'iptables')
def reset(self):
self.enabled = True
self.blacklist_cache = None
for chain in (self.chain, self.new_chain):
try:
self._clean_up(chain)
except Exception as e:
LOG.exception('Encountered exception resetting filter: %s', e)
super(IptablesFilter, self).reset()
@pxe_filter.locked_driver_event(pxe_filter.Events.initialize)
def init_filter(self):
# -w flag makes iptables wait for xtables lock, but it's not supported
# everywhere yet
try:
with open(os.devnull, 'wb') as null:
subprocess.check_call(self.base_command + ('-w', '-h'),
stderr=null, stdout=null)
except subprocess.CalledProcessError:
LOG.warning('iptables does not support -w flag, please update '
'it to at least version 1.4.21')
else:
self.base_command += ('-w',)
self._clean_up(self.chain)
# Not really needed, but helps to validate that we have access to
# iptables
self._iptables('-N', self.chain)
LOG.debug('The iptables filter was initialized')
@pxe_filter.locked_driver_event(pxe_filter.Events.sync)
def sync(self, ironic):
"""Sync firewall filter rules for introspection.
Gives access to PXE boot port for any machine, except for those, whose
MAC is registered in Ironic and is not on introspection right now.
This function is called from both introspection initialization code and
from periodic task. This function is supposed to be resistant to
unexpected iptables state.
``init()`` function must be called once before any call to this
function. This function is using ``eventlet`` semaphore to serialize
access from different green threads.
:param ironic: an ironic client instance.
:returns: nothing.
"""
if not _should_enable_dhcp():
self._disable_dhcp()
return
to_blacklist = _get_blacklist(ironic)
if to_blacklist == self.blacklist_cache:
LOG.debug('Not updating iptables - no changes in MAC list %s',
to_blacklist)
return
LOG.debug('Blacklisting active MAC\'s %s', to_blacklist)
with self._temporary_chain(self.new_chain, self.chain):
# Force update on the next iteration if this attempt fails
self.blacklist_cache = None
# - Blacklist active macs, so that nova can boot them
for mac in to_blacklist:
self._iptables('-A', self.new_chain, '-m', 'mac',
'--mac-source', mac, '-j', 'DROP')
# - Whitelist everything else
self._iptables('-A', self.new_chain, '-j', 'ACCEPT')
# Cache result of successful iptables update
self.enabled = True
self.blacklist_cache = to_blacklist
LOG.debug('The iptables filter was synchronized')
@contextlib.contextmanager
def _temporary_chain(self, chain, main_chain):
"""Context manager to operate on a temporary chain."""
# Clean up a bit to account for possible troubles on previous run
self._clean_up(chain)
self._iptables('-N', chain)
yield
# Swap chains
self._iptables('-I', 'INPUT', '-i', self.interface, '-p', 'udp',
'--dport', '67', '-j', chain)
self._iptables('-D', 'INPUT', '-i', self.interface, '-p', 'udp',
'--dport', '67', '-j', main_chain,
ignore=True)
self._iptables('-F', main_chain, ignore=True)
self._iptables('-X', main_chain, ignore=True)
self._iptables('-E', chain, main_chain)
def _iptables(self, *args, **kwargs):
# NOTE(dtantsur): -w flag makes it wait for xtables lock
cmd = self.base_command + args
ignore = kwargs.pop('ignore', False)
LOG.debug('Running iptables %s', args)
kwargs['stderr'] = subprocess.STDOUT
try:
subprocess.check_output(cmd, **kwargs)
except subprocess.CalledProcessError as exc:
output = exc.output.replace('\n', '. ')
if ignore:
LOG.debug('Ignoring failed iptables %(args)s: %(output)s',
{'args': args, 'output': output})
else:
LOG.error('iptables %(iptables)s failed: %(exc)s',
{'iptables': args, 'exc': output})
raise
def _clean_up(self, chain):
self._iptables('-D', 'INPUT', '-i', self.interface, '-p', 'udp',
'--dport', '67', '-j', chain,
ignore=True)
self._iptables('-F', chain, ignore=True)
self._iptables('-X', chain, ignore=True)
def _disable_dhcp(self):
"""Disable DHCP completely."""
if not self.enabled:
LOG.debug('DHCP is already disabled, not updating')
return
LOG.debug('No nodes on introspection and node_not_found_hook is '
'not set - disabling DHCP')
self.blacklist_cache = None
with self._temporary_chain(self.new_chain, self.chain):
# Blacklist everything
self._iptables('-A', self.new_chain, '-j', 'REJECT')
self.enabled = False
def _ib_mac_to_rmac_mapping(ports):
"""Update port InfiniBand MAC address to EthernetOverInfiniBand MAC
On InfiniBand deployment we need to map between the baremetal host
InfiniBand MAC to the EoIB MAC. The EoIB MAC addresses are learned
automatically by the EoIB interfaces and those MACs are recorded to the
/sys/class/net/<ethoib_interface>/eth/neighs file. The InfiniBand GUID is
taken from the ironic port client-id extra attribute. The InfiniBand GUID
is the last 8 bytes of the client-id. The file format allows to map the
GUID to EoIB MAC. The filter rules based on those MACs get applied during a
driver.update() call
:param ports: list of ironic ports
:returns: Nothing.
"""
ethoib_interfaces = CONF.iptables.ethoib_interfaces
for interface in ethoib_interfaces:
neighs_file = (
os.path.join('/sys/class/net', interface, 'eth/neighs'))
try:
with open(neighs_file, 'r') as fd:
data = fd.read()
except IOError:
LOG.error('Interface %s is not Ethernet Over InfiniBand; '
'Skipping ...', interface)
continue
for port in ports:
client_id = port.extra.get('client-id')
if client_id:
# Note(moshele): The last 8 bytes in the client-id is
# the baremetal node InfiniBand GUID
guid = client_id[-23:]
p = re.compile(_EMAC_REGEX + guid)
match = p.search(data)
if match:
port.address = match.group(1)
def _get_blacklist(ironic):
ports = [
port.address for port in ironic.port.list(
limit=0, fields=['address', 'extra'])
if port.address not in node_cache.active_macs()]
_ib_mac_to_rmac_mapping(ports)
return ports

4
ironic_inspector/test/functional.py

@ -53,8 +53,8 @@ os_auth_url = http://url
os_username = user
os_password = password
os_tenant_name = tenant
[firewall]
manage_firewall = False
[pxe_filter]
driver = noop
[DEFAULT]
debug = True
auth_strategy = noauth

444
ironic_inspector/test/unit/test_firewall.py

@ -1,444 +0,0 @@
# Copyright 2015 NEC Corporation
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import mock
from oslo_config import cfg
from ironic_inspector.common import ironic as ir_utils
from ironic_inspector import firewall
from ironic_inspector import introspection_state as istate
from ironic_inspector import node_cache
from ironic_inspector.test import base as test_base
CONF = cfg.CONF
IB_DATA = """
EMAC=02:00:02:97:00:01 IMAC=97:fe:80:00:00:00:00:00:00:7c:fe:90:03:00:29:26:52
EMAC=02:00:00:61:00:02 IMAC=61:fe:80:00:00:00:00:00:00:7c:fe:90:03:00:29:24:4f
"""
@mock.patch.object(firewall, '_iptables')
@mock.patch.object(ir_utils, 'get_client')
@mock.patch.object(firewall.subprocess, 'check_call')
class TestFirewall(test_base.NodeTest):
CLIENT_ID = 'ff:00:00:00:00:00:02:00:00:02:c9:00:7c:fe:90:03:00:29:24:4f'
def test_update_filters_without_manage_firewall(self, mock_call,
mock_get_client,
mock_iptables):
CONF.set_override('manage_firewall', False, 'firewall')
firewall.update_filters()
self.assertEqual(0, mock_iptables.call_count)
def test_init_args(self, mock_call, mock_get_client, mock_iptables):
rootwrap_path = '/some/fake/path'
CONF.set_override('rootwrap_config', rootwrap_path)
firewall.init()
init_expected_args = [
('-D', 'INPUT', '-i', 'br-ctlplane', '-p', 'udp', '--dport', '67',
'-j', CONF.firewall.firewall_chain),
('-F', CONF.firewall.firewall_chain),
('-X', CONF.firewall.firewall_chain),
('-N', CONF.firewall.firewall_chain)]
call_args_list = mock_iptables.call_args_list
for (args, call) in zip(init_expected_args, call_args_list):
self.assertEqual(args, call[0])
expected = ('sudo', 'ironic-inspector-rootwrap', rootwrap_path,
'iptables', '-w')
self.assertEqual(expected, firewall.BASE_COMMAND)
def test_init_args_old_iptables(self, mock_call, mock_get_client,
mock_iptables):
rootwrap_path = '/some/fake/path'
CONF.set_override('rootwrap_config', rootwrap_path)
mock_call.side_effect = firewall.subprocess.CalledProcessError(2, '')
firewall.init()
init_expected_args = [
('-D', 'INPUT', '-i', 'br-ctlplane', '-p', 'udp', '--dport', '67',
'-j', CONF.firewall.firewall_chain),
('-F', CONF.firewall.firewall_chain),
('-X', CONF.firewall.firewall_chain),
('-N', CONF.firewall.firewall_chain)]
call_args_list = mock_iptables.call_args_list
for (args, call) in zip(init_expected_args, call_args_list):
self.assertEqual(args, call[0])
expected = ('sudo', 'ironic-inspector-rootwrap', rootwrap_path,
'iptables',)
self.assertEqual(expected, firewall.BASE_COMMAND)
def test_init_kwargs(self, mock_call, mock_get_client, mock_iptables):
firewall.init()
init_expected_kwargs = [
{'ignore': True},
{'ignore': True},
{'ignore': True}]
call_args_list = mock_iptables.call_args_list
for (kwargs, call) in zip(init_expected_kwargs, call_args_list):
self.assertEqual(kwargs, call[1])
def test_update_filters_args(self, mock_call, mock_get_client,
mock_iptables):
# Pretend that we have nodes on introspection
node_cache.add_node(self.node.uuid, state=istate.States.waiting,
bmc_address='1.2.3.4')
firewall.init()
update_filters_expected_args = [
('-D', 'INPUT', '-i', 'br-ctlplane', '-p', 'udp', '--dport',
'67', '-j', CONF.firewall.firewall_chain),
('-F', CONF.firewall.firewall_chain),
('-X', CONF.firewall.firewall_chain),
('-N', CONF.firewall.firewall_chain),
('-D', 'INPUT', '-i', 'br-ctlplane', '-p', 'udp', '--dport',
'67', '-j', firewall.NEW_CHAIN),
('-F', firewall.NEW_CHAIN),
('-X', firewall.NEW_CHAIN),
('-N', firewall.NEW_CHAIN),
('-A', firewall.NEW_CHAIN, '-j', 'ACCEPT'),
('-I', 'INPUT', '-i', 'br-ctlplane', '-p', 'udp', '--dport',
'67', '-j', firewall.NEW_CHAIN),
('-D', 'INPUT', '-i', 'br-ctlplane', '-p', 'udp', '--dport',
'67', '-j', CONF.firewall.firewall_chain),
('-F', CONF.firewall.firewall_chain),
('-X', CONF.firewall.firewall_chain),
('-E', firewall.NEW_CHAIN, CONF.firewall.firewall_chain)
]
firewall.update_filters()
call_args_list = mock_iptables.call_args_list
for (args, call) in zip(update_filters_expected_args,
call_args_list):
self.assertEqual(args, call[0])
def test_update_filters_kwargs(self, mock_call, mock_get_client,
mock_iptables):
firewall.init()
update_filters_expected_kwargs = [
{'ignore': True},
{'ignore': True},
{'ignore': True},
{},
{'ignore': True},
{'ignore': True},
{'ignore': True},
{},
{},
{},
{'ignore': True},
{'ignore': True},
{'ignore': True}
]
firewall.update_filters()
call_args_list = mock_iptables.call_args_list
for (kwargs, call) in zip(update_filters_expected_kwargs,
call_args_list):
self.assertEqual(kwargs, call[1])
def test_update_filters_with_blacklist(self, mock_call, mock_get_client,
mock_iptables):
active_macs = ['11:22:33:44:55:66', '66:55:44:33:22:11']
inactive_mac = ['AA:BB:CC:DD:EE:FF']
self.macs = active_macs + inactive_mac
self.ports = [mock.Mock(address=m) for m in self.macs]
mock_get_client.port.list.return_value = self.ports
node_cache.add_node(self.node.uuid, mac=active_macs,
state=istate.States.finished,
bmc_address='1.2.3.4', foo=None)
firewall.init()
update_filters_expected_args = [
('-D', 'INPUT', '-i', 'br-ctlplane', '-p', 'udp', '--dport',
'67', '-j', CONF.firewall.firewall_chain),
('-F', CONF.firewall.firewall_chain),
('-X', CONF.firewall.firewall_chain),
('-N', CONF.firewall.firewall_chain),
('-D', 'INPUT', '-i', 'br-ctlplane', '-p', 'udp', '--dport',
'67', '-j', firewall.NEW_CHAIN),
('-F', firewall.NEW_CHAIN),
('-X', firewall.NEW_CHAIN),
('-N', firewall.NEW_CHAIN),
# Blacklist
('-A', firewall.NEW_CHAIN, '-m', 'mac', '--mac-source',
inactive_mac[0], '-j', 'DROP'),
('-A', firewall.NEW_CHAIN, '-j', 'ACCEPT'),
('-I', 'INPUT', '-i', 'br-ctlplane', '-p', 'udp', '--dport',
'67', '-j', firewall.NEW_CHAIN),
('-D', 'INPUT', '-i', 'br-ctlplane', '-p', 'udp', '--dport',
'67', '-j', CONF.firewall.firewall_chain),
('-F', CONF.firewall.firewall_chain),
('-X', CONF.firewall.firewall_chain),
('-E', firewall.NEW_CHAIN, CONF.firewall.firewall_chain)
]
firewall.update_filters(mock_get_client)
call_args_list = mock_iptables.call_args_list
for (args, call) in zip(update_filters_expected_args,
call_args_list):
self.assertEqual(args, call[0])
# check caching
mock_iptables.reset_mock()
firewall.update_filters(mock_get_client)
self.assertFalse(mock_iptables.called)
def test_update_filters_clean_cache_on_error(self, mock_call,
mock_get_client,
mock_iptables):
active_macs = ['11:22:33:44:55:66', '66:55:44:33:22:11']
inactive_mac = ['AA:BB:CC:DD:EE:FF']
self.macs = active_macs + inactive_mac
self.ports = [mock.Mock(address=m) for m in self.macs]
mock_get_client.port.list.return_value = self.ports
node_cache.add_node(self.node.uuid, mac=active_macs,
state=istate.States.finished,
bmc_address='1.2.3.4', foo=None)
firewall.init()
update_filters_expected_args = [
('-D', 'INPUT', '-i', 'br-ctlplane', '-p', 'udp', '--dport',
'67', '-j', firewall.NEW_CHAIN),
('-F', firewall.NEW_CHAIN),
('-X', firewall.NEW_CHAIN),
('-N', firewall.NEW_CHAIN),
# Blacklist
('-A', firewall.NEW_CHAIN, '-m', 'mac', '--mac-source',
inactive_mac[0], '-j', 'DROP'),
('-A', firewall.NEW_CHAIN, '-j', 'ACCEPT'),
('-I', 'INPUT', '-i', 'br-ctlplane', '-p', 'udp', '--dport',
'67', '-j', firewall.NEW_CHAIN),
('-D', 'INPUT', '-i', 'br-ctlplane', '-p', 'udp', '--dport',
'67', '-j', CONF.firewall.firewall_chain),
('-F', CONF.firewall.firewall_chain),
('-X', CONF.firewall.firewall_chain),
('-E', firewall.NEW_CHAIN, CONF.firewall.firewall_chain)
]
mock_iptables.side_effect = [None, None, RuntimeError()]
self.assertRaises(RuntimeError, firewall.update_filters,
mock_get_client)
# check caching
mock_iptables.reset_mock()
mock_iptables.side_effect = None
firewall.update_filters(mock_get_client)
call_args_list = mock_iptables.call_args_list
for (args, call) in zip(update_filters_expected_args,
call_args_list):
self.assertEqual(args, call[0])
def test_update_filters_args_node_not_found_hook(self, mock_call,
mock_get_client,
mock_iptables):
# DHCP should be always opened if node_not_found hook is set
CONF.set_override('node_not_found_hook', 'enroll', 'processing')
firewall.init()
update_filters_expected_args = [
('-D', 'INPUT', '-i', 'br-ctlplane', '-p', 'udp', '--dport',
'67', '-j', CONF.firewall.firewall_chain),
('-F', CONF.firewall.firewall_chain),
('-X', CONF.firewall.firewall_chain),
('-N', CONF.firewall.firewall_chain),
('-D', 'INPUT', '-i', 'br-ctlplane', '-p', 'udp', '--dport',
'67', '-j', firewall.NEW_CHAIN),
('-F', firewall.NEW_CHAIN),
('-X', firewall.NEW_CHAIN),
('-N', firewall.NEW_CHAIN),
('-A', firewall.NEW_CHAIN, '-j', 'ACCEPT'),
('-I', 'INPUT', '-i', 'br-ctlplane', '-p', 'udp', '--dport',
'67', '-j', firewall.NEW_CHAIN),
('-D', 'INPUT', '-i', 'br-ctlplane', '-p', 'udp', '--dport',
'67', '-j', CONF.firewall.firewall_chain),
('-F', CONF.firewall.firewall_chain),
('-X', CONF.firewall.firewall_chain),
('-E', firewall.NEW_CHAIN, CONF.firewall.firewall_chain)
]
firewall.update_filters()
call_args_list = mock_iptables.call_args_list
for (args, call) in zip(update_filters_expected_args,
call_args_list):
self.assertEqual(args, call[0])
def test_update_filters_args_no_introspection(self, mock_call,
mock_get_client,
mock_iptables):
firewall.init()
firewall.BLACKLIST_CACHE = ['foo']
mock_get_client.return_value.port.list.return_value = [
mock.Mock(address='foobar')]
update_filters_expected_args = [
('-D', 'INPUT', '-i', 'br-ctlplane', '-p', 'udp', '--dport',
'67', '-j', CONF.firewall.firewall_chain),
('-F', CONF.firewall.firewall_chain),
('-X', CONF.firewall.firewall_chain),
('-N', CONF.firewall.firewall_chain),
('-D', 'INPUT', '-i', 'br-ctlplane', '-p', 'udp', '--dport',
'67', '-j', firewall.NEW_CHAIN),
('-F', firewall.NEW_CHAIN),
('-X', firewall.NEW_CHAIN),
('-N', firewall.NEW_CHAIN),
('-A', firewall.NEW_CHAIN, '-j', 'REJECT'),
('-I', 'INPUT', '-i', 'br-ctlplane', '-p', 'udp', '--dport',
'67', '-j', firewall.NEW_CHAIN),
('-D', 'INPUT', '-i', 'br-ctlplane', '-p', 'udp', '--dport',
'67', '-j', CONF.firewall.firewall_chain),
('-F', CONF.firewall.firewall_chain),
('-X', CONF.firewall.firewall_chain),
('-E', firewall.NEW_CHAIN, CONF.firewall.firewall_chain)
]
firewall.update_filters()
call_args_list = mock_iptables.call_args_list
for (args, call) in zip(update_filters_expected_args,
call_args_list):
self.assertEqual(args, call[0])
self.assertIsNone(firewall.BLACKLIST_CACHE)
# Check caching enabled flag
mock_iptables.reset_mock()
firewall.update_filters()
self.assertFalse(mock_iptables.called)
# Adding a node changes it back
node_cache.add_node(self.node.uuid, state=istate.States.starting,
bmc_address='1.2.3.4')
mock_iptables.reset_mock()
firewall.update_filters()
mock_iptables.assert_any_call('-A', firewall.NEW_CHAIN, '-j', 'ACCEPT')
self.assertEqual({'foobar'}, firewall.BLACKLIST_CACHE)
def test_update_filters_infiniband(
self, mock_call, mock_get_client, mock_iptables):
CONF.set_override('ethoib_interfaces', ['eth0'], 'firewall')
active_macs = ['11:22:33:44:55:66', '66:55:44:33:22:11']
expected_rmac = '02:00:00:61:00:02'
ports = [mock.Mock(address=m) for m in active_macs]
ports.append(mock.Mock(address='7c:fe:90:29:24:4f',
extra={'client-id': self.CLIENT_ID},
spec=['address', 'extra']))
mock_get_client.port.list.return_value = ports
node_cache.add_node(self.node.uuid, mac=active_macs,
state=istate.States.finished,
bmc_address='1.2.3.4', foo=None)
firewall.init()
update_filters_expected_args = [
('-D', 'INPUT', '-i', 'br-ctlplane', '-p', 'udp', '--dport',
'67', '-j', CONF.firewall.firewall_chain),
('-F', CONF.firewall.firewall_chain),
('-X', CONF.firewall.firewall_chain),
('-N', CONF.firewall.firewall_chain),
('-D', 'INPUT', '-i', 'br-ctlplane', '-p', 'udp', '--dport',
'67', '-j', firewall.NEW_CHAIN),
('-F', firewall.NEW_CHAIN),
('-X', firewall.NEW_CHAIN),
('-N', firewall.NEW_CHAIN),
# Blacklist
('-A', firewall.NEW_CHAIN, '-m', 'mac', '--mac-source',
expected_rmac, '-j', 'DROP'),
('-A', firewall.NEW_CHAIN, '-j', 'ACCEPT'),
('-I', 'INPUT', '-i', 'br-ctlplane', '-p', 'udp', '--dport',
'67', '-j', firewall.NEW_CHAIN),
('-D', 'INPUT', '-i', 'br-ctlplane', '-p', 'udp', '--dport',
'67', '-j', CONF.firewall.firewall_chain),
('-F', CONF.firewall.firewall_chain),
('-X', CONF.firewall.firewall_chain),
('-E', firewall.NEW_CHAIN, CONF.firewall.firewall_chain)
]
fileobj = mock.mock_open(read_data=IB_DATA)
with mock.patch('six.moves.builtins.open', fileobj, create=True):
firewall.update_filters(mock_get_client)
call_args_list = mock_iptables.call_args_list
for (args, call) in zip(update_filters_expected_args,
call_args_list):
self.assertEqual(args, call[0])
def test_update_filters_infiniband_no_such_file(
self, mock_call, mock_get_client, mock_iptables):
CONF.set_override('ethoib_interfaces', ['eth0'], 'firewall')
active_macs = ['11:22:33:44:55:66', '66:55:44:33:22:11']
ports = [mock.Mock(address=m) for m in active_macs]
ports.append(mock.Mock(address='7c:fe:90:29:24:4f',
extra={'client-id': self.CLIENT_ID},
spec=['address', 'extra']))
mock_get_client.port.list.return_value = ports
node_cache.add_node(self.node.uuid, mac=active_macs,
state=istate.States.finished,
bmc_address='1.2.3.4', foo=None)
firewall.init()
update_filters_expected_args = [
('-D', 'INPUT', '-i', 'br-ctlplane', '-p', 'udp', '--dport',
'67', '-j', CONF.firewall.firewall_chain),
('-F', CONF.firewall.firewall_chain),
('-X', CONF.firewall.firewall_chain),
('-N', CONF.firewall.firewall_chain),
('-D', 'INPUT', '-i', 'br-ctlplane', '-p', 'udp', '--dport',
'67', '-j', firewall.NEW_CHAIN),
('-F', firewall.NEW_CHAIN),
('-X', firewall.NEW_CHAIN),
('-N', firewall.NEW_CHAIN),
# Blacklist
('-A', firewall.NEW_CHAIN, '-m', 'mac', '--mac-source',
'7c:fe:90:29:24:4f', '-j', 'DROP'),
('-A', firewall.NEW_CHAIN, '-j', 'ACCEPT'),
('-I', 'INPUT', '-i', 'br-ctlplane', '-p', 'udp', '--dport',
'67', '-j', firewall.NEW_CHAIN),
('-D', 'INPUT', '-i', 'br-ctlplane', '-p', 'udp', '--dport',
'67', '-j', CONF.firewall.firewall_chain),
('-F', CONF.firewall.firewall_chain),
('-X', CONF.firewall.firewall_chain),
('-E', firewall.NEW_CHAIN, CONF.firewall.firewall_chain)
]
with mock.patch('six.moves.builtins.open', side_effect=IOError()):
firewall.update_filters(mock_get_client)
call_args_list = mock_iptables.call_args_list
for (args, call) in zip(update_filters_expected_args,
call_args_list):
self.assertEqual(args, call[0])

87
ironic_inspector/test/unit/test_introspect.py

@ -14,14 +14,15 @@
import collections
import time
import fixtures
from ironicclient import exceptions
import mock
from oslo_config import cfg
from ironic_inspector.common import ironic as ir_utils
from ironic_inspector import firewall
from ironic_inspector import introspect
from ironic_inspector import node_cache
from ironic_inspector.pxe_filter import base as pxe_filter
from ironic_inspector.test import base as test_base
from ironic_inspector import utils
@ -39,6 +40,10 @@ class BaseTest(test_base.NodeTest):
self.node_info = mock.Mock(uuid=self.uuid, options={})
self.node_info.ports.return_value = self.ports_dict
self.node_info.node.return_value = self.node
driver_fixture = self.useFixture(fixtures.MockPatchObject(
pxe_filter, 'driver', autospec=True))
driver_mock = driver_fixture.mock.return_value
self.sync_filter_mock = driver_mock.sync
def _prepare(self, client_mock):
cli = client_mock.return_value
@ -47,11 +52,10 @@ class BaseTest(test_base.NodeTest):
return cli
@mock.patch.object(firewall, 'update_filters', autospec=True)
@mock.patch.object(node_cache, 'start_introspection', autospec=True)
@mock.patch.object(ir_utils, 'get_client', autospec=True)
class TestIntrospect(BaseTest):
def test_ok(self, client_mock, start_mock, filters_mock):
def test_ok(self, client_mock, start_mock):
cli = self._prepare(client_mock)
start_mock.return_value = self.node_info
@ -66,7 +70,7 @@ class TestIntrospect(BaseTest):
self.node_info.ports.assert_called_once_with()
self.node_info.add_attribute.assert_called_once_with('mac',
self.macs)
filters_mock.assert_called_with(cli)
self.sync_filter_mock.assert_called_with(cli)
cli.node.set_boot_device.assert_called_once_with(self.uuid,
'pxe',
persistent=False)
@ -75,7 +79,7 @@ class TestIntrospect(BaseTest):
self.node_info.acquire_lock.assert_called_once_with()
self.node_info.release_lock.assert_called_once_with()
def test_loopback_bmc_address(self, client_mock