258 lines
8.8 KiB
Python
258 lines
8.8 KiB
Python
# 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
|