# 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 fcntl
import os
import time
from oslo_log import log
from ironic.conf import CONF
LOG = log.getLogger(__name__)
[docs]
def update(allow_macs, deny_macs, allow_unknown=None):
"""Update only the given MACs.
MACs not in either lists are ignored.
:param allow_macs: MACs to allow in dnsmasq.
:param deny_macs: MACs to disallow in dnsmasq.
:param allow_unknown: If set to True, unknown MACs are also allowed.
Setting it to False does nothing in this call.
"""
for mac in allow_macs:
_add_mac_to_allowlist(mac)
for mac in deny_macs:
_add_mac_to_denylist(mac)
if allow_unknown:
_configure_unknown_hosts(True)
[docs]
def sync(allow_macs, deny_macs, allow_unknown):
"""Conduct a complete sync of the state.
Unlike ``update``, MACs not in either list are handled according
to ``allow_unknown``.
:param allow_macs: MACs to allow in dnsmasq.
:param deny_macs: MACs to disallow in dnsmasq.
:param allow_unknown: Whether to allow access to dnsmasq to unknown
MACs.
"""
allow_macs = set(allow_macs)
deny_macs = set(deny_macs)
known_macs = allow_macs.union(deny_macs)
current_denylist, current_allowlist = _get_deny_allow_lists()
removed_macs = current_denylist.union(current_allowlist).difference(
known_macs)
update(allow_macs=allow_macs.difference(current_allowlist),
deny_macs=deny_macs.difference(current_denylist))
# Allow or deny unknown hosts and MACs not kept in ironic
# NOTE(hjensas): Treat unknown hosts and MACs not kept in ironic the
# same. Neither should boot the inspection image unless inspection
# is active. Deleted MACs must be added to the allow list when
# inspection is active in case the host is re-enrolled.
_configure_unknown_hosts(allow_unknown)
_configure_removedlist(removed_macs, allow_unknown)
_EXCLUSIVE_WRITE_ATTEMPTS = 10
_EXCLUSIVE_WRITE_ATTEMPTS_DELAY = 0.01
_MAC_DENY_LEN = len('ff:ff:ff:ff:ff:ff,ignore\n')
_MAC_ALLOW_LEN = len('ff:ff:ff:ff:ff:ff\n')
_UNKNOWN_HOSTS_FILE = 'unknown_hosts_filter'
_DENY_UNKNOWN_HOSTS = '*:*:*:*:*:*,ignore\n'
_ALLOW_UNKNOWN_HOSTS = '*:*:*:*:*:*\n'
def _get_deny_allow_lists():
"""Get addresses currently denied by dnsmasq.
:raises: FileNotFoundError in case the dhcp_hostsdir is invalid.
:returns: tuple with 2 elements: a set of MACs currently denied by dnsmasq
and a set of allowed MACs.
"""
hostsdir = CONF.pxe_filter.dhcp_hostsdir
# MACs in the allow list lack the ,ignore directive
denylist = set()
allowlist = set()
for mac in os.listdir(hostsdir):
if os.stat(os.path.join(hostsdir, mac)).st_size == _MAC_DENY_LEN:
denylist.add(mac)
if os.stat(os.path.join(hostsdir, mac)).st_size == _MAC_ALLOW_LEN:
allowlist.add(mac)
return denylist, allowlist
def _exclusive_write_or_pass(path, buf):
"""Write exclusively or pass if path locked.
The intention is to be able to run multiple instances of the filter on the
same node in multiple inspector processes.
:param path: where to write to
:param buf: the content to write
:raises: FileNotFoundError, IOError
:returns: True if the write was successful.
"""
# NOTE(milan) line-buffering enforced to ensure dnsmasq record update
# through inotify, which reacts on f.close()
with open(path, 'w', 1) as f:
for attempt in range(_EXCLUSIVE_WRITE_ATTEMPTS):
try:
fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
f.write(buf)
# Go ahead and flush the data now instead of waiting until
# after the automatic flush with the file close after the
# file lock is released.
f.flush()
return True
except BlockingIOError:
LOG.debug('%s locked; will try again (later)', path)
time.sleep(_EXCLUSIVE_WRITE_ATTEMPTS_DELAY)
continue
finally:
fcntl.flock(f, fcntl.LOCK_UN)
LOG.debug('Failed to write the exclusively-locked path: %(path)s for '
'%(attempts)s times', {'attempts': _EXCLUSIVE_WRITE_ATTEMPTS,
'path': path})
return False
def _configure_removedlist(macs, allowed):
"""Manages a dhcp_hostsdir allow/deny record for removed macs
:raises: FileNotFoundError in case the dhcp_hostsdir is invalid,
:returns: None.
"""
hostsdir = CONF.pxe_filter.dhcp_hostsdir
for mac in macs:
file_size = os.stat(os.path.join(hostsdir, mac)).st_size
if allowed:
if file_size != _MAC_ALLOW_LEN:
_add_mac_to_allowlist(mac)
else:
if file_size != _MAC_DENY_LEN:
_add_mac_to_denylist(mac)
def _configure_unknown_hosts(enabled):
"""Manages a dhcp_hostsdir allow/deny record for unknown macs.
:raises: FileNotFoundError in case the dhcp_hostsdir is invalid,
IOError in case the dhcp host unknown file isn't writable.
:returns: None.
"""
path = os.path.join(CONF.pxe_filter.dhcp_hostsdir, _UNKNOWN_HOSTS_FILE)
if enabled:
wildcard_filter = _ALLOW_UNKNOWN_HOSTS
log_wildcard_filter = 'allow'
else:
wildcard_filter = _DENY_UNKNOWN_HOSTS
log_wildcard_filter = 'deny'
# Don't update if unknown hosts are already in the deny/allow-list
try:
if os.stat(path).st_size == len(wildcard_filter):
return
except FileNotFoundError:
pass
if _exclusive_write_or_pass(path, '%s' % wildcard_filter):
LOG.debug('A %s record for all unknown hosts using wildcard mac '
'created', log_wildcard_filter)
else:
LOG.warning('Failed to %s unknown hosts using wildcard mac; '
'retrying next periodic sync time', log_wildcard_filter)
def _add_mac_to_denylist(mac):
"""Creates a dhcp_hostsdir deny record for the MAC.
:raises: FileNotFoundError in case the dhcp_hostsdir is invalid,
IOError in case the dhcp host MAC file isn't writable.
:returns: None.
"""
path = os.path.join(CONF.pxe_filter.dhcp_hostsdir, mac)
if _exclusive_write_or_pass(path, '%s,ignore\n' % mac):
LOG.debug('MAC %s added to the deny list', mac)
else:
LOG.warning('Failed to add MAC %s to the deny list; retrying next '
'periodic sync time', mac)
def _add_mac_to_allowlist(mac):
"""Update the dhcp_hostsdir record for the MAC adding it to allow list
:raises: FileNotFoundError in case the dhcp_hostsdir is invalid,
IOError in case the dhcp host MAC file isn't writable.
:returns: None.
"""
path = os.path.join(CONF.pxe_filter.dhcp_hostsdir, mac)
# remove the ,ignore directive
if _exclusive_write_or_pass(path, '%s\n' % mac):
LOG.debug('MAC %s removed from the deny list', mac)
else:
LOG.warning('Failed to remove MAC %s from the deny list; retrying '
'next periodic sync time', mac)