1601 lines
67 KiB
Python
1601 lines
67 KiB
Python
# Copyright 2012 OpenStack Foundation
|
|
# 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 abc
|
|
import collections
|
|
import os
|
|
import re
|
|
import shutil
|
|
import time
|
|
|
|
import netaddr
|
|
from neutron_lib.api.definitions import extra_dhcp_opt as edo_ext
|
|
from neutron_lib import constants
|
|
from neutron_lib import exceptions
|
|
from neutron_lib.utils import file as file_utils
|
|
from oslo_log import log as logging
|
|
from oslo_utils import excutils
|
|
from oslo_utils import fileutils
|
|
from oslo_utils import uuidutils
|
|
import six
|
|
|
|
from neutron._i18n import _
|
|
from neutron.agent.common import utils as agent_common_utils
|
|
from neutron.agent.linux import external_process
|
|
from neutron.agent.linux import ip_lib
|
|
from neutron.agent.linux import iptables_manager
|
|
from neutron.cmd import runtime_checks as checks
|
|
from neutron.common import constants as n_const
|
|
from neutron.common import exceptions as n_exc
|
|
from neutron.common import ipv6_utils
|
|
from neutron.common import utils as common_utils
|
|
from neutron.ipam import utils as ipam_utils
|
|
|
|
LOG = logging.getLogger(__name__)
|
|
|
|
UDP = 'udp'
|
|
TCP = 'tcp'
|
|
DNS_PORT = 53
|
|
DHCPV4_PORT = 67
|
|
DHCPV6_PORT = 547
|
|
METADATA_DEFAULT_PREFIX = 16
|
|
METADATA_DEFAULT_IP = '169.254.169.254'
|
|
METADATA_DEFAULT_CIDR = '%s/%d' % (METADATA_DEFAULT_IP,
|
|
METADATA_DEFAULT_PREFIX)
|
|
METADATA_PORT = 80
|
|
WIN2k3_STATIC_DNS = 249
|
|
NS_PREFIX = 'qdhcp-'
|
|
DNSMASQ_SERVICE_NAME = 'dnsmasq'
|
|
DHCP_RELEASE_TRIES = 3
|
|
DHCP_RELEASE_TRIES_SLEEP = 0.3
|
|
|
|
# this variable will be removed when neutron-lib is updated with this value
|
|
DHCP_OPT_CLIENT_ID_NUM = 61
|
|
|
|
|
|
class DictModel(dict):
|
|
"""Convert dict into an object that provides attribute access to values."""
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
"""Convert dict values to DictModel values."""
|
|
super(DictModel, self).__init__(*args, **kwargs)
|
|
|
|
def needs_upgrade(item):
|
|
"""Check if `item` is a dict and needs to be changed to DictModel.
|
|
"""
|
|
return isinstance(item, dict) and not isinstance(item, DictModel)
|
|
|
|
def upgrade(item):
|
|
"""Upgrade item if it needs to be upgraded."""
|
|
if needs_upgrade(item):
|
|
return DictModel(item)
|
|
else:
|
|
return item
|
|
|
|
for key, value in self.items():
|
|
if isinstance(value, (list, tuple)):
|
|
# Keep the same type but convert dicts to DictModels
|
|
self[key] = type(value)(
|
|
(upgrade(item) for item in value)
|
|
)
|
|
elif needs_upgrade(value):
|
|
# Change dict instance values to DictModel instance values
|
|
self[key] = DictModel(value)
|
|
|
|
def __getattr__(self, name):
|
|
try:
|
|
return self[name]
|
|
except KeyError as e:
|
|
raise AttributeError(e)
|
|
|
|
def __setattr__(self, name, value):
|
|
self[name] = value
|
|
|
|
def __delattr__(self, name):
|
|
del self[name]
|
|
|
|
def __str__(self):
|
|
pairs = ['%s=%s' % (k, v) for k, v in self.items()]
|
|
return ', '.join(sorted(pairs))
|
|
|
|
|
|
class NetModel(DictModel):
|
|
|
|
def __init__(self, d):
|
|
super(NetModel, self).__init__(d)
|
|
|
|
self._ns_name = "%s%s" % (NS_PREFIX, self.id)
|
|
|
|
@property
|
|
def namespace(self):
|
|
return self._ns_name
|
|
|
|
|
|
@six.add_metaclass(abc.ABCMeta)
|
|
class DhcpBase(object):
|
|
|
|
def __init__(self, conf, network, process_monitor,
|
|
version=None, plugin=None):
|
|
self.conf = conf
|
|
self.network = network
|
|
self.process_monitor = process_monitor
|
|
self.device_manager = DeviceManager(self.conf, plugin)
|
|
self.version = version
|
|
|
|
@abc.abstractmethod
|
|
def enable(self):
|
|
"""Enables DHCP for this network."""
|
|
|
|
@abc.abstractmethod
|
|
def disable(self, retain_port=False, block=False):
|
|
"""Disable dhcp for this network."""
|
|
|
|
def restart(self):
|
|
"""Restart the dhcp service for the network."""
|
|
self.disable(retain_port=True, block=True)
|
|
self.enable()
|
|
|
|
@abc.abstractproperty
|
|
def active(self):
|
|
"""Boolean representing the running state of the DHCP server."""
|
|
|
|
@abc.abstractmethod
|
|
def reload_allocations(self):
|
|
"""Force the DHCP server to reload the assignment database."""
|
|
|
|
@classmethod
|
|
def existing_dhcp_networks(cls, conf):
|
|
"""Return a list of existing networks ids that we have configs for."""
|
|
|
|
raise NotImplementedError()
|
|
|
|
@classmethod
|
|
def check_version(cls):
|
|
"""Execute version checks on DHCP server."""
|
|
|
|
raise NotImplementedError()
|
|
|
|
@classmethod
|
|
def get_isolated_subnets(cls, network):
|
|
"""Returns a dict indicating whether or not a subnet is isolated"""
|
|
raise NotImplementedError()
|
|
|
|
@classmethod
|
|
def should_enable_metadata(cls, conf, network):
|
|
"""True if the metadata-proxy should be enabled for the network."""
|
|
raise NotImplementedError()
|
|
|
|
|
|
@six.add_metaclass(abc.ABCMeta)
|
|
class DhcpLocalProcess(DhcpBase):
|
|
PORTS = []
|
|
|
|
def __init__(self, conf, network, process_monitor, version=None,
|
|
plugin=None):
|
|
super(DhcpLocalProcess, self).__init__(conf, network, process_monitor,
|
|
version, plugin)
|
|
self.confs_dir = self.get_confs_dir(conf)
|
|
self.network_conf_dir = os.path.join(self.confs_dir, network.id)
|
|
fileutils.ensure_tree(self.network_conf_dir, mode=0o755)
|
|
|
|
@staticmethod
|
|
def get_confs_dir(conf):
|
|
return os.path.abspath(os.path.normpath(conf.dhcp_confs))
|
|
|
|
def get_conf_file_name(self, kind):
|
|
"""Returns the file name for a given kind of config file."""
|
|
return os.path.join(self.network_conf_dir, kind)
|
|
|
|
def _remove_config_files(self):
|
|
shutil.rmtree(self.network_conf_dir, ignore_errors=True)
|
|
|
|
@staticmethod
|
|
def _get_all_subnets(network):
|
|
non_local_subnets = getattr(network, 'non_local_subnets', [])
|
|
return network.subnets + non_local_subnets
|
|
|
|
def _enable_dhcp(self):
|
|
"""check if there is a subnet within the network with dhcp enabled."""
|
|
for subnet in self.network.subnets:
|
|
if subnet.enable_dhcp:
|
|
return True
|
|
return False
|
|
|
|
def enable(self):
|
|
"""Enables DHCP for this network by spawning a local process."""
|
|
try:
|
|
common_utils.wait_until_true(self._enable, timeout=300)
|
|
except common_utils.WaitTimeout:
|
|
LOG.error("Failed to start DHCP process for network %s",
|
|
self.network.id)
|
|
|
|
def _enable(self):
|
|
try:
|
|
if self.active:
|
|
self.restart()
|
|
elif self._enable_dhcp():
|
|
fileutils.ensure_tree(self.network_conf_dir, mode=0o755)
|
|
interface_name = self.device_manager.setup(self.network)
|
|
self.interface_name = interface_name
|
|
self.spawn_process()
|
|
return True
|
|
except n_exc.ProcessExecutionError as error:
|
|
LOG.debug("Spawning DHCP process for network %s failed; "
|
|
"Error: %s", self.network.id, error)
|
|
return False
|
|
|
|
def _get_process_manager(self, cmd_callback=None):
|
|
return external_process.ProcessManager(
|
|
conf=self.conf,
|
|
uuid=self.network.id,
|
|
namespace=self.network.namespace,
|
|
default_cmd_callback=cmd_callback,
|
|
pid_file=self.get_conf_file_name('pid'),
|
|
run_as_root=True)
|
|
|
|
def disable(self, retain_port=False, block=False):
|
|
"""Disable DHCP for this network by killing the local process."""
|
|
self.process_monitor.unregister(self.network.id, DNSMASQ_SERVICE_NAME)
|
|
self._get_process_manager().disable()
|
|
if block:
|
|
common_utils.wait_until_true(lambda: not self.active)
|
|
if not retain_port:
|
|
self._destroy_namespace_and_port()
|
|
self._remove_config_files()
|
|
|
|
def _destroy_namespace_and_port(self):
|
|
try:
|
|
self.device_manager.destroy(self.network, self.interface_name)
|
|
except RuntimeError:
|
|
LOG.warning('Failed trying to delete interface: %s',
|
|
self.interface_name)
|
|
|
|
if not ip_lib.network_namespace_exists(self.network.namespace):
|
|
LOG.debug("Namespace already deleted: %s", self.network.namespace)
|
|
return
|
|
try:
|
|
ip_lib.delete_network_namespace(self.network.namespace)
|
|
except RuntimeError:
|
|
LOG.warning('Failed trying to delete namespace: %s',
|
|
self.network.namespace)
|
|
|
|
def _get_value_from_conf_file(self, kind, converter=None):
|
|
"""A helper function to read a value from one of the state files."""
|
|
file_name = self.get_conf_file_name(kind)
|
|
msg = _('Error while reading %s')
|
|
|
|
try:
|
|
with open(file_name, 'r') as f:
|
|
try:
|
|
return converter(f.read()) if converter else f.read()
|
|
except ValueError:
|
|
msg = _('Unable to convert value in %s')
|
|
except IOError:
|
|
msg = _('Unable to access %s')
|
|
|
|
LOG.debug(msg, file_name)
|
|
return None
|
|
|
|
@property
|
|
def interface_name(self):
|
|
return self._get_value_from_conf_file('interface')
|
|
|
|
@interface_name.setter
|
|
def interface_name(self, value):
|
|
interface_file_path = self.get_conf_file_name('interface')
|
|
file_utils.replace_file(interface_file_path, value)
|
|
|
|
@property
|
|
def active(self):
|
|
return self._get_process_manager().active
|
|
|
|
@abc.abstractmethod
|
|
def spawn_process(self):
|
|
pass
|
|
|
|
|
|
class Dnsmasq(DhcpLocalProcess):
|
|
# The ports that need to be opened when security policies are active
|
|
# on the Neutron port used for DHCP. These are provided as a convenience
|
|
# for users of this class.
|
|
PORTS = {constants.IP_VERSION_4:
|
|
[(UDP, DNS_PORT), (TCP, DNS_PORT), (UDP, DHCPV4_PORT)],
|
|
constants.IP_VERSION_6:
|
|
[(UDP, DNS_PORT), (TCP, DNS_PORT), (UDP, DHCPV6_PORT)],
|
|
}
|
|
|
|
_SUBNET_TAG_PREFIX = 'subnet-%s'
|
|
_PORT_TAG_PREFIX = 'port-%s'
|
|
|
|
_ID = 'id:'
|
|
|
|
_IS_DHCP_RELEASE6_SUPPORTED = None
|
|
|
|
@classmethod
|
|
def check_version(cls):
|
|
pass
|
|
|
|
@classmethod
|
|
def existing_dhcp_networks(cls, conf):
|
|
"""Return a list of existing networks ids that we have configs for."""
|
|
confs_dir = cls.get_confs_dir(conf)
|
|
try:
|
|
return [
|
|
c for c in os.listdir(confs_dir)
|
|
if uuidutils.is_uuid_like(c)
|
|
]
|
|
except OSError:
|
|
return []
|
|
|
|
def _build_cmdline_callback(self, pid_file):
|
|
# We ignore local resolv.conf if dns servers are specified
|
|
# or if local resolution is explicitly disabled.
|
|
_no_resolv = (
|
|
'--no-resolv' if self.conf.dnsmasq_dns_servers or
|
|
not self.conf.dnsmasq_local_resolv else '')
|
|
cmd = [
|
|
'dnsmasq',
|
|
'--no-hosts',
|
|
_no_resolv,
|
|
'--pid-file=%s' % pid_file,
|
|
'--dhcp-hostsfile=%s' % self.get_conf_file_name('host'),
|
|
'--addn-hosts=%s' % self.get_conf_file_name('addn_hosts'),
|
|
'--dhcp-optsfile=%s' % self.get_conf_file_name('opts'),
|
|
'--dhcp-leasefile=%s' % self.get_conf_file_name('leases'),
|
|
'--dhcp-match=set:ipxe,175',
|
|
'--local-service',
|
|
'--bind-dynamic',
|
|
]
|
|
if not self.device_manager.driver.bridged:
|
|
cmd += [
|
|
'--bridge-interface=%s,tap*' % self.interface_name,
|
|
]
|
|
|
|
possible_leases = 0
|
|
for subnet in self._get_all_subnets(self.network):
|
|
mode = None
|
|
# if a subnet is specified to have dhcp disabled
|
|
if not subnet.enable_dhcp:
|
|
continue
|
|
if subnet.ip_version == 4:
|
|
mode = 'static'
|
|
else:
|
|
# Note(scollins) If the IPv6 attributes are not set, set it as
|
|
# static to preserve previous behavior
|
|
addr_mode = getattr(subnet, 'ipv6_address_mode', None)
|
|
ra_mode = getattr(subnet, 'ipv6_ra_mode', None)
|
|
if (addr_mode in [constants.DHCPV6_STATEFUL,
|
|
constants.DHCPV6_STATELESS] or
|
|
not addr_mode and not ra_mode):
|
|
mode = 'static'
|
|
|
|
cidr = netaddr.IPNetwork(subnet.cidr)
|
|
|
|
if self.conf.dhcp_lease_duration == -1:
|
|
lease = 'infinite'
|
|
else:
|
|
lease = '%ss' % self.conf.dhcp_lease_duration
|
|
|
|
# mode is optional and is not set - skip it
|
|
if mode:
|
|
if subnet.ip_version == 4:
|
|
cmd.append('--dhcp-range=%s%s,%s,%s,%s,%s' %
|
|
('set:', self._SUBNET_TAG_PREFIX % subnet.id,
|
|
cidr.network, mode, cidr.netmask, lease))
|
|
else:
|
|
if cidr.prefixlen < 64:
|
|
LOG.debug('Ignoring subnet %(subnet)s, CIDR has '
|
|
'prefix length < 64: %(cidr)s',
|
|
{'subnet': subnet.id, 'cidr': cidr})
|
|
continue
|
|
cmd.append('--dhcp-range=%s%s,%s,%s,%d,%s' %
|
|
('set:', self._SUBNET_TAG_PREFIX % subnet.id,
|
|
cidr.network, mode,
|
|
cidr.prefixlen, lease))
|
|
possible_leases += cidr.size
|
|
|
|
mtu = getattr(self.network, 'mtu', 0)
|
|
# Do not advertise unknown mtu
|
|
if mtu > 0:
|
|
cmd.append('--dhcp-option-force=option:mtu,%d' % mtu)
|
|
|
|
# Cap the limit because creating lots of subnets can inflate
|
|
# this possible lease cap.
|
|
cmd.append('--dhcp-lease-max=%d' %
|
|
min(possible_leases, self.conf.dnsmasq_lease_max))
|
|
|
|
if self.conf.dhcp_renewal_time > 0:
|
|
cmd.append('--dhcp-option-force=option:T1,%ds' %
|
|
self.conf.dhcp_renewal_time)
|
|
|
|
if self.conf.dhcp_rebinding_time > 0:
|
|
cmd.append('--dhcp-option-force=option:T2,%ds' %
|
|
self.conf.dhcp_rebinding_time)
|
|
|
|
cmd.append('--conf-file=%s' % self.conf.dnsmasq_config_file)
|
|
for server in self.conf.dnsmasq_dns_servers:
|
|
cmd.append('--server=%s' % server)
|
|
|
|
if self.conf.dns_domain:
|
|
cmd.append('--domain=%s' % self.conf.dns_domain)
|
|
|
|
if self.conf.dhcp_broadcast_reply:
|
|
cmd.append('--dhcp-broadcast')
|
|
|
|
if self.conf.dnsmasq_base_log_dir:
|
|
log_dir = os.path.join(
|
|
self.conf.dnsmasq_base_log_dir,
|
|
self.network.id)
|
|
try:
|
|
if not os.path.exists(log_dir):
|
|
os.makedirs(log_dir)
|
|
except OSError:
|
|
LOG.error('Error while create dnsmasq log dir: %s', log_dir)
|
|
else:
|
|
log_filename = os.path.join(log_dir, 'dhcp_dns_log')
|
|
cmd.append('--log-queries')
|
|
cmd.append('--log-dhcp')
|
|
cmd.append('--log-facility=%s' % log_filename)
|
|
|
|
return cmd
|
|
|
|
def spawn_process(self):
|
|
"""Spawn the process, if it's not spawned already."""
|
|
# we only need to generate the lease file the first time dnsmasq starts
|
|
# rather than on every reload since dnsmasq will keep the file current
|
|
self._output_init_lease_file()
|
|
self._spawn_or_reload_process(reload_with_HUP=False)
|
|
|
|
def _spawn_or_reload_process(self, reload_with_HUP):
|
|
"""Spawns or reloads a Dnsmasq process for the network.
|
|
|
|
When reload_with_HUP is True, dnsmasq receives a HUP signal,
|
|
or it's reloaded if the process is not running.
|
|
"""
|
|
|
|
self._output_config_files()
|
|
|
|
pm = self._get_process_manager(
|
|
cmd_callback=self._build_cmdline_callback)
|
|
|
|
pm.enable(reload_cfg=reload_with_HUP)
|
|
|
|
self.process_monitor.register(uuid=self.network.id,
|
|
service_name=DNSMASQ_SERVICE_NAME,
|
|
monitored_process=pm)
|
|
|
|
def _is_dhcp_release6_supported(self):
|
|
if self._IS_DHCP_RELEASE6_SUPPORTED is None:
|
|
self._IS_DHCP_RELEASE6_SUPPORTED = checks.dhcp_release6_supported()
|
|
if not self._IS_DHCP_RELEASE6_SUPPORTED:
|
|
LOG.warning("dhcp_release6 is not present on this system, "
|
|
"will not call it again.")
|
|
return self._IS_DHCP_RELEASE6_SUPPORTED
|
|
|
|
def _release_lease(self, mac_address, ip, ip_version, client_id=None,
|
|
server_id=None, iaid=None):
|
|
"""Release a DHCP lease."""
|
|
if ip_version == constants.IP_VERSION_6:
|
|
if not self._is_dhcp_release6_supported():
|
|
return
|
|
cmd = ['dhcp_release6', '--iface', self.interface_name,
|
|
'--ip', ip, '--client-id', client_id,
|
|
'--server-id', server_id, '--iaid', iaid]
|
|
else:
|
|
cmd = ['dhcp_release', self.interface_name, ip, mac_address]
|
|
if client_id:
|
|
cmd.append(client_id)
|
|
ip_wrapper = ip_lib.IPWrapper(namespace=self.network.namespace)
|
|
try:
|
|
ip_wrapper.netns.execute(cmd, run_as_root=True)
|
|
except RuntimeError as e:
|
|
# when failed to release single lease there's
|
|
# no need to propagate error further
|
|
LOG.warning('DHCP release failed for %(cmd)s. '
|
|
'Reason: %(e)s', {'cmd': cmd, 'e': e})
|
|
|
|
def _output_config_files(self):
|
|
self._output_hosts_file()
|
|
self._output_addn_hosts_file()
|
|
self._output_opts_file()
|
|
|
|
def reload_allocations(self):
|
|
"""Rebuild the dnsmasq config and signal the dnsmasq to reload."""
|
|
|
|
# If all subnets turn off dhcp, kill the process.
|
|
if not self._enable_dhcp():
|
|
self.disable()
|
|
LOG.debug('Killing dnsmasq for network since all subnets have '
|
|
'turned off DHCP: %s', self.network.id)
|
|
return
|
|
if not self.interface_name:
|
|
# we land here if above has been called and we receive port
|
|
# delete notifications for the network
|
|
LOG.debug('Agent does not have an interface on this network '
|
|
'anymore, skipping reload: %s', self.network.id)
|
|
return
|
|
|
|
self._release_unused_leases()
|
|
self._spawn_or_reload_process(reload_with_HUP=True)
|
|
LOG.debug('Reloading allocations for network: %s', self.network.id)
|
|
self.device_manager.update(self.network, self.interface_name)
|
|
|
|
def _sort_fixed_ips_for_dnsmasq(self, fixed_ips, v6_nets):
|
|
"""Sort fixed_ips so that stateless IPv6 subnets appear first.
|
|
|
|
For example, If a port with v6 extra_dhcp_opts is on a network with
|
|
IPv4 and IPv6 stateless subnets. Then dhcp host file will have
|
|
below 2 entries for same MAC,
|
|
|
|
fa:16:3e:8f:9d:65,30.0.0.5,set:aabc7d33-4874-429e-9637-436e4232d2cd
|
|
(entry for IPv4 dhcp)
|
|
fa:16:3e:8f:9d:65,set:aabc7d33-4874-429e-9637-436e4232d2cd
|
|
(entry for stateless IPv6 for v6 options)
|
|
|
|
dnsmasq internal details for processing host file entries
|
|
1) dnsmasq reads the host file from EOF.
|
|
2) So it first picks up stateless IPv6 entry,
|
|
fa:16:3e:8f:9d:65,set:aabc7d33-4874-429e-9637-436e4232d2cd
|
|
3) But dnsmasq doesn't have sufficient checks to skip this entry and
|
|
pick next entry, to process dhcp IPv4 request.
|
|
4) So dnsmasq uses this entry to process dhcp IPv4 request.
|
|
5) As there is no ip in this entry, dnsmasq logs "no address available"
|
|
and fails to send DHCPOFFER message.
|
|
|
|
As we rely on internal details of dnsmasq to understand and fix the
|
|
issue, Ihar sent a mail to dnsmasq-discuss mailing list
|
|
http://lists.thekelleys.org.uk/pipermail/dnsmasq-discuss/2015q2/
|
|
009650.html
|
|
|
|
So if we reverse the order of writing entries in host file,
|
|
so that entry for stateless IPv6 comes first,
|
|
then dnsmasq can correctly fetch the IPv4 address.
|
|
"""
|
|
return sorted(
|
|
fixed_ips,
|
|
key=lambda fip: ((fip.subnet_id in v6_nets) and (
|
|
v6_nets[fip.subnet_id].ipv6_address_mode == (
|
|
constants.DHCPV6_STATELESS))),
|
|
reverse=True)
|
|
|
|
def _iter_hosts(self):
|
|
"""Iterate over hosts.
|
|
|
|
For each host on the network we yield a tuple containing:
|
|
(
|
|
port, # a DictModel instance representing the port.
|
|
alloc, # a DictModel instance of the allocated ip and subnet.
|
|
# if alloc is None, it means there is no need to allocate
|
|
# an IPv6 address because of stateless DHCPv6 network.
|
|
host_name, # Host name.
|
|
name, # Canonical hostname in the format 'hostname[.domain]'.
|
|
no_dhcp, # A flag indicating that the address doesn't need a DHCP
|
|
# IP address.
|
|
no_opts, # A flag indication that options shouldn't be written
|
|
)
|
|
"""
|
|
v6_nets = dict((subnet.id, subnet) for subnet in
|
|
self._get_all_subnets(self.network)
|
|
if subnet.ip_version == 6)
|
|
|
|
for port in self.network.ports:
|
|
fixed_ips = self._sort_fixed_ips_for_dnsmasq(port.fixed_ips,
|
|
v6_nets)
|
|
# Confirm whether Neutron server supports dns_name attribute in the
|
|
# ports API
|
|
dns_assignment = getattr(port, 'dns_assignment', None)
|
|
if dns_assignment:
|
|
dns_ip_map = {d.ip_address: d for d in dns_assignment}
|
|
for alloc in fixed_ips:
|
|
no_dhcp = False
|
|
no_opts = False
|
|
if alloc.subnet_id in v6_nets:
|
|
addr_mode = v6_nets[alloc.subnet_id].ipv6_address_mode
|
|
no_dhcp = addr_mode in (constants.IPV6_SLAAC,
|
|
constants.DHCPV6_STATELESS)
|
|
# we don't setup anything for SLAAC. It doesn't make sense
|
|
# to provide options for a client that won't use DHCP
|
|
no_opts = addr_mode == constants.IPV6_SLAAC
|
|
|
|
# If dns_name attribute is supported by ports API, return the
|
|
# dns_assignment generated by the Neutron server. Otherwise,
|
|
# generate hostname and fqdn locally (previous behaviour)
|
|
if dns_assignment:
|
|
hostname = dns_ip_map[alloc.ip_address].hostname
|
|
fqdn = dns_ip_map[alloc.ip_address].fqdn
|
|
else:
|
|
hostname = 'host-%s' % alloc.ip_address.replace(
|
|
'.', '-').replace(':', '-')
|
|
fqdn = hostname
|
|
if self.conf.dns_domain:
|
|
fqdn = '%s.%s' % (fqdn, self.conf.dns_domain)
|
|
yield (port, alloc, hostname, fqdn, no_dhcp, no_opts)
|
|
|
|
def _get_port_extra_dhcp_opts(self, port):
|
|
return getattr(port, edo_ext.EXTRADHCPOPTS, False)
|
|
|
|
def _output_init_lease_file(self):
|
|
"""Write a fake lease file to bootstrap dnsmasq.
|
|
|
|
The generated file is passed to the --dhcp-leasefile option of dnsmasq.
|
|
This is used as a bootstrapping mechanism to avoid NAKing active leases
|
|
when a dhcp server is scheduled to another agent. Using a leasefile
|
|
will also prevent dnsmasq from NAKing or ignoring renewals after a
|
|
restart.
|
|
|
|
Format is as follows:
|
|
epoch-timestamp mac_addr ip_addr hostname client-ID
|
|
"""
|
|
filename = self.get_conf_file_name('leases')
|
|
buf = six.StringIO()
|
|
|
|
LOG.debug('Building initial lease file: %s', filename)
|
|
# we make up a lease time for the database entry
|
|
if self.conf.dhcp_lease_duration == -1:
|
|
# Even with an infinite lease, a client may choose to renew a
|
|
# previous lease on reboot or interface bounce so we should have
|
|
# an entry for it.
|
|
# Dnsmasq timestamp format for an infinite lease is 0.
|
|
timestamp = 0
|
|
else:
|
|
timestamp = int(time.time()) + self.conf.dhcp_lease_duration
|
|
dhcpv4_enabled_subnet_ids = [
|
|
s.id for s in self._get_all_subnets(self.network)
|
|
if s.enable_dhcp and s.ip_version == constants.IP_VERSION_4]
|
|
for host_tuple in self._iter_hosts():
|
|
port, alloc, hostname, name, no_dhcp, no_opts = host_tuple
|
|
# don't write ip address which belongs to a dhcp disabled subnet
|
|
# or an IPv6 subnet.
|
|
if no_dhcp or alloc.subnet_id not in dhcpv4_enabled_subnet_ids:
|
|
continue
|
|
|
|
# all that matters is the mac address and IP. the hostname and
|
|
# client ID will be overwritten on the next renewal.
|
|
buf.write('%s %s %s * *\n' %
|
|
(timestamp, port.mac_address, alloc.ip_address))
|
|
contents = buf.getvalue()
|
|
file_utils.replace_file(filename, contents)
|
|
LOG.debug('Done building initial lease file %s with contents:\n%s',
|
|
filename, contents)
|
|
return filename
|
|
|
|
@staticmethod
|
|
def _format_address_for_dnsmasq(address):
|
|
# (dzyu) Check if it is legal ipv6 address, if so, need wrap
|
|
# it with '[]' to let dnsmasq to distinguish MAC address from
|
|
# IPv6 address.
|
|
if netaddr.valid_ipv6(address):
|
|
return '[%s]' % address
|
|
return address
|
|
|
|
def _output_hosts_file(self):
|
|
"""Writes a dnsmasq compatible dhcp hosts file.
|
|
|
|
The generated file is sent to the --dhcp-hostsfile option of dnsmasq,
|
|
and lists the hosts on the network which should receive a dhcp lease.
|
|
Each line in this file is in the form::
|
|
|
|
'mac_address,FQDN,ip_address'
|
|
|
|
IMPORTANT NOTE: a dnsmasq instance does not resolve hosts defined in
|
|
this file if it did not give a lease to a host listed in it (e.g.:
|
|
multiple dnsmasq instances on the same network if this network is on
|
|
multiple network nodes). This file is only defining hosts which
|
|
should receive a dhcp lease, the hosts resolution in itself is
|
|
defined by the `_output_addn_hosts_file` method.
|
|
"""
|
|
buf = six.StringIO()
|
|
filename = self.get_conf_file_name('host')
|
|
|
|
LOG.debug('Building host file: %s', filename)
|
|
dhcp_enabled_subnet_ids = [s.id for s in
|
|
self._get_all_subnets(self.network)
|
|
if s.enable_dhcp]
|
|
# NOTE(ihrachyshka): the loop should not log anything inside it, to
|
|
# avoid potential performance drop when lots of hosts are dumped
|
|
for host_tuple in self._iter_hosts():
|
|
port, alloc, hostname, name, no_dhcp, no_opts = host_tuple
|
|
if no_dhcp:
|
|
if not no_opts and self._get_port_extra_dhcp_opts(port):
|
|
buf.write('%s,%s%s\n' % (
|
|
port.mac_address,
|
|
'set:', self._PORT_TAG_PREFIX % port.id))
|
|
continue
|
|
|
|
# don't write ip address which belongs to a dhcp disabled subnet.
|
|
if alloc.subnet_id not in dhcp_enabled_subnet_ids:
|
|
continue
|
|
|
|
ip_address = self._format_address_for_dnsmasq(alloc.ip_address)
|
|
|
|
if self._get_port_extra_dhcp_opts(port):
|
|
client_id = self._get_client_id(port)
|
|
if client_id and len(port.extra_dhcp_opts) > 1:
|
|
buf.write('%s,%s%s,%s,%s,%s%s\n' %
|
|
(port.mac_address, self._ID, client_id, name,
|
|
ip_address, 'set:',
|
|
self._PORT_TAG_PREFIX % port.id))
|
|
elif client_id and len(port.extra_dhcp_opts) == 1:
|
|
buf.write('%s,%s%s,%s,%s\n' %
|
|
(port.mac_address, self._ID, client_id, name,
|
|
ip_address))
|
|
else:
|
|
buf.write('%s,%s,%s,%s%s\n' %
|
|
(port.mac_address, name, ip_address,
|
|
'set:', self._PORT_TAG_PREFIX % port.id))
|
|
else:
|
|
buf.write('%s,%s,%s\n' %
|
|
(port.mac_address, name, ip_address))
|
|
|
|
file_utils.replace_file(filename, buf.getvalue())
|
|
LOG.debug('Done building host file %s', filename)
|
|
return filename
|
|
|
|
def _get_client_id(self, port):
|
|
if self._get_port_extra_dhcp_opts(port):
|
|
for opt in port.extra_dhcp_opts:
|
|
if opt.opt_name in (edo_ext.DHCP_OPT_CLIENT_ID,
|
|
DHCP_OPT_CLIENT_ID_NUM,
|
|
str(DHCP_OPT_CLIENT_ID_NUM)):
|
|
return opt.opt_value
|
|
|
|
def _read_hosts_file_leases(self, filename):
|
|
leases = set()
|
|
try:
|
|
with open(filename) as f:
|
|
for l in f.readlines():
|
|
host = l.strip().split(',')
|
|
mac = host[0]
|
|
client_id = None
|
|
if host[1].startswith('set:'):
|
|
continue
|
|
if host[1].startswith(self._ID):
|
|
ip = host[3].strip('[]')
|
|
client_id = host[1][len(self._ID):]
|
|
else:
|
|
ip = host[2].strip('[]')
|
|
leases.add((ip, mac, client_id))
|
|
except (OSError, IOError):
|
|
LOG.debug('Error while reading hosts file %s', filename)
|
|
return leases
|
|
|
|
def _read_leases_file_leases(self, filename, ip_version=None):
|
|
"""
|
|
Read information from leases file, which is needed to pass to
|
|
dhcp_release6 command line utility if some of these leases are not
|
|
needed anymore
|
|
|
|
each line in dnsmasq leases file is one of the following
|
|
* duid entry: duid server_duid
|
|
There MUST be single duid entry per file
|
|
* ipv4 entry: space separated list
|
|
- The expiration time (seconds since unix epoch) or duration
|
|
(if dnsmasq is compiled with HAVE_BROKEN_RTC) of the lease.
|
|
0 means infinite.
|
|
- The link address, in format XX-YY:YY:YY[...], where XX is the ARP
|
|
hardware type. "XX-" may be omitted for Ethernet.
|
|
- The IPv4 address
|
|
- The hostname (sent by the client or assigned by dnsmasq)
|
|
or '*' for none.
|
|
- The client identifier (colon-separated hex bytes)
|
|
or '*' for none.
|
|
|
|
* ipv6 entry: space separated list
|
|
- The expiration time or duration
|
|
- The IAID as a Big Endian decimal number, prefixed by T for
|
|
IA_TAs (temporary addresses).
|
|
- The IPv6 address
|
|
- The hostname or '*'
|
|
- The client DUID (colon-separated hex bytes) or '*' if unknown
|
|
|
|
original discussion is in dnsmasq mailing list
|
|
http://lists.thekelleys.org.uk/pipermail/\
|
|
dnsmasq-discuss/2016q2/010595.html
|
|
|
|
:param filename: leases file
|
|
:param ip_version: IP version of entries to return, or None for all
|
|
:return: dict, keys are IP(v6) addresses, values are dicts containing
|
|
iaid, client_id and server_id
|
|
"""
|
|
leases = {}
|
|
server_id = None
|
|
if os.path.exists(filename):
|
|
with open(filename) as f:
|
|
for l in f.readlines():
|
|
if l.startswith('duid'):
|
|
if not server_id:
|
|
server_id = l.strip().split()[1]
|
|
continue
|
|
else:
|
|
LOG.warning('Multiple DUID entries in %s '
|
|
'lease file, dnsmasq is possibly '
|
|
'not functioning properly',
|
|
filename)
|
|
continue
|
|
parts = l.strip().split()
|
|
if len(parts) != 5:
|
|
LOG.warning('Invalid lease entry %s found in %s '
|
|
'lease file, ignoring', parts, filename)
|
|
continue
|
|
(iaid, ip, client_id) = parts[1], parts[2], parts[4]
|
|
ip = ip.strip('[]')
|
|
if (ip_version and
|
|
netaddr.IPAddress(ip).version != ip_version):
|
|
continue
|
|
leases[ip] = {'iaid': iaid,
|
|
'client_id': client_id,
|
|
'server_id': server_id
|
|
}
|
|
return leases
|
|
|
|
def _release_unused_leases(self):
|
|
filename = self.get_conf_file_name('host')
|
|
old_leases = self._read_hosts_file_leases(filename)
|
|
leases_filename = self.get_conf_file_name('leases')
|
|
cur_leases = self._read_leases_file_leases(leases_filename)
|
|
if not cur_leases:
|
|
return
|
|
|
|
v4_leases = set()
|
|
for (k, v) in cur_leases.items():
|
|
# IPv4 leases have a MAC, IPv6 ones do not, so we must ignore
|
|
if netaddr.IPAddress(k).version == constants.IP_VERSION_4:
|
|
# treat '*' as None, see note in _read_leases_file_leases()
|
|
client_id = v['client_id']
|
|
if client_id is '*':
|
|
client_id = None
|
|
v4_leases.add((k, v['iaid'], client_id))
|
|
|
|
new_leases = set()
|
|
for port in self.network.ports:
|
|
client_id = self._get_client_id(port)
|
|
for alloc in port.fixed_ips:
|
|
new_leases.add((alloc.ip_address, port.mac_address, client_id))
|
|
|
|
# If an entry is in the leases or host file(s), but doesn't have
|
|
# a fixed IP on a corresponding neutron port, consider it stale.
|
|
entries_to_release = (v4_leases | old_leases) - new_leases
|
|
if not entries_to_release:
|
|
return
|
|
|
|
# If the VM advertises a client ID in its lease, but its not set in
|
|
# the port's Extra DHCP Opts, the lease will not be filtered above.
|
|
# Release the lease only if client ID is set in port DB and a mismatch
|
|
# Otherwise the lease is released when other ports are deleted/updated
|
|
entries_with_no_client_id = set()
|
|
for ip, mac, client_id in entries_to_release:
|
|
if client_id:
|
|
entry_no_client_id = (ip, mac, None)
|
|
if (entry_no_client_id in old_leases and
|
|
entry_no_client_id in new_leases):
|
|
entries_with_no_client_id.add((ip, mac, client_id))
|
|
entries_to_release -= entries_with_no_client_id
|
|
|
|
# Try DHCP_RELEASE_TRIES times to release a lease, re-reading the
|
|
# file each time to see if it's still there. We loop +1 times to
|
|
# check the lease file one last time before logging any remaining
|
|
# entries.
|
|
for i in range(DHCP_RELEASE_TRIES + 1):
|
|
entries_not_present = set()
|
|
for ip, mac, client_id in entries_to_release:
|
|
try:
|
|
entry = cur_leases[ip]
|
|
except KeyError:
|
|
entries_not_present.add((ip, mac, client_id))
|
|
continue
|
|
# if not the final loop, try and release
|
|
if i < DHCP_RELEASE_TRIES:
|
|
ip_version = netaddr.IPAddress(ip).version
|
|
if ip_version == constants.IP_VERSION_6:
|
|
client_id = entry['client_id']
|
|
self._release_lease(mac, ip, ip_version, client_id,
|
|
entry['server_id'], entry['iaid'])
|
|
|
|
# Remove elements that were not in the current leases file,
|
|
# no need to look for them again, and see if we're done.
|
|
entries_to_release -= entries_not_present
|
|
if not entries_to_release:
|
|
break
|
|
|
|
if i < DHCP_RELEASE_TRIES:
|
|
time.sleep(DHCP_RELEASE_TRIES_SLEEP)
|
|
cur_leases = self._read_leases_file_leases(leases_filename)
|
|
if not cur_leases:
|
|
break
|
|
else:
|
|
LOG.warning("Could not release DHCP leases for these IP "
|
|
"addresses after %d tries: %s",
|
|
DHCP_RELEASE_TRIES,
|
|
', '.join(ip for ip, m, c in entries_to_release))
|
|
|
|
def _output_addn_hosts_file(self):
|
|
"""Writes a dnsmasq compatible additional hosts file.
|
|
|
|
The generated file is sent to the --addn-hosts option of dnsmasq,
|
|
and lists the hosts on the network which should be resolved even if
|
|
the dnsmasq instance did not give a lease to the host (see the
|
|
`_output_hosts_file` method).
|
|
Each line in this file is in the same form as a standard /etc/hosts
|
|
file.
|
|
"""
|
|
buf = six.StringIO()
|
|
for host_tuple in self._iter_hosts():
|
|
port, alloc, hostname, fqdn, no_dhcp, no_opts = host_tuple
|
|
# It is compulsory to write the `fqdn` before the `hostname` in
|
|
# order to obtain it in PTR responses.
|
|
if alloc:
|
|
buf.write('%s\t%s %s\n' % (alloc.ip_address, fqdn, hostname))
|
|
addn_hosts = self.get_conf_file_name('addn_hosts')
|
|
file_utils.replace_file(addn_hosts, buf.getvalue())
|
|
return addn_hosts
|
|
|
|
def _output_opts_file(self):
|
|
"""Write a dnsmasq compatible options file."""
|
|
options, subnet_index_map = self._generate_opts_per_subnet()
|
|
options += self._generate_opts_per_port(subnet_index_map)
|
|
|
|
name = self.get_conf_file_name('opts')
|
|
file_utils.replace_file(name, '\n'.join(options))
|
|
return name
|
|
|
|
def _generate_opts_per_subnet(self):
|
|
options = []
|
|
subnets_without_nameservers = set()
|
|
if self.conf.enable_isolated_metadata or self.conf.force_metadata:
|
|
subnet_to_interface_ip = self._make_subnet_interface_ip_map()
|
|
isolated_subnets = self.get_isolated_subnets(self.network)
|
|
for subnet in self._get_all_subnets(self.network):
|
|
addr_mode = getattr(subnet, 'ipv6_address_mode', None)
|
|
segment_id = getattr(subnet, 'segment_id', None)
|
|
if (not subnet.enable_dhcp or
|
|
(subnet.ip_version == 6 and
|
|
addr_mode == constants.IPV6_SLAAC)):
|
|
continue
|
|
if subnet.dns_nameservers:
|
|
if ((subnet.ip_version == 4 and
|
|
subnet.dns_nameservers == ['0.0.0.0']) or
|
|
(subnet.ip_version == 6 and
|
|
subnet.dns_nameservers == ['::'])):
|
|
# Special case: Do not announce DNS servers
|
|
options.append(
|
|
self._format_option(
|
|
subnet.ip_version,
|
|
self._SUBNET_TAG_PREFIX % subnet.id,
|
|
'dns-server'))
|
|
else:
|
|
options.append(
|
|
self._format_option(
|
|
subnet.ip_version,
|
|
self._SUBNET_TAG_PREFIX % subnet.id,
|
|
'dns-server', ','.join(
|
|
Dnsmasq._convert_to_literal_addrs(
|
|
subnet.ip_version,
|
|
subnet.dns_nameservers))))
|
|
else:
|
|
# use the dnsmasq ip as nameservers only if there is no
|
|
# dns-server submitted by the server
|
|
# Here is something to check still
|
|
subnets_without_nameservers.add(subnet.id)
|
|
|
|
if self.conf.dns_domain and subnet.ip_version == 6:
|
|
# This should be change also
|
|
options.append(
|
|
self._format_option(
|
|
subnet.ip_version, self._SUBNET_TAG_PREFIX % subnet.id,
|
|
"domain-search", ''.join(self.conf.dns_domain)))
|
|
|
|
gateway = subnet.gateway_ip
|
|
host_routes = []
|
|
for hr in subnet.host_routes:
|
|
if hr.destination == constants.IPv4_ANY:
|
|
if not gateway:
|
|
gateway = hr.nexthop
|
|
else:
|
|
host_routes.append("%s,%s" % (hr.destination, hr.nexthop))
|
|
|
|
# Add host routes for isolated network segments
|
|
|
|
if ((self.conf.force_metadata or
|
|
(isolated_subnets[subnet.id] and
|
|
self.conf.enable_isolated_metadata)) and
|
|
subnet.ip_version == 4):
|
|
subnet_dhcp_ip = subnet_to_interface_ip.get(subnet.id)
|
|
if subnet_dhcp_ip:
|
|
host_routes.append(
|
|
'%s/32,%s' % (METADATA_DEFAULT_IP, subnet_dhcp_ip)
|
|
)
|
|
elif not isolated_subnets[subnet.id] and gateway:
|
|
host_routes.append(
|
|
'%s/32,%s' % (METADATA_DEFAULT_IP, gateway)
|
|
)
|
|
|
|
if subnet.ip_version == 4:
|
|
for s in self._get_all_subnets(self.network):
|
|
sub_segment_id = getattr(s, 'segment_id', None)
|
|
if (s.ip_version == 4 and
|
|
s.cidr != subnet.cidr and
|
|
sub_segment_id == segment_id):
|
|
host_routes.append("%s,0.0.0.0" % s.cidr)
|
|
|
|
if host_routes:
|
|
if gateway:
|
|
host_routes.append("%s,%s" % (constants.IPv4_ANY,
|
|
gateway))
|
|
options.append(
|
|
self._format_option(
|
|
subnet.ip_version,
|
|
self._SUBNET_TAG_PREFIX % subnet.id,
|
|
'classless-static-route',
|
|
','.join(host_routes)))
|
|
options.append(
|
|
self._format_option(
|
|
subnet.ip_version,
|
|
self._SUBNET_TAG_PREFIX % subnet.id,
|
|
WIN2k3_STATIC_DNS,
|
|
','.join(host_routes)))
|
|
|
|
if gateway:
|
|
options.append(self._format_option(
|
|
subnet.ip_version, self._SUBNET_TAG_PREFIX % subnet.id,
|
|
'router', gateway))
|
|
else:
|
|
options.append(self._format_option(
|
|
subnet.ip_version, self._SUBNET_TAG_PREFIX % subnet.id,
|
|
'router'))
|
|
return options, subnets_without_nameservers
|
|
|
|
def _generate_opts_per_port(self, subnets_without_nameservers):
|
|
options = []
|
|
dhcp_ips = collections.defaultdict(list)
|
|
for port in self.network.ports:
|
|
if self._get_port_extra_dhcp_opts(port):
|
|
port_ip_versions = set(
|
|
[netaddr.IPAddress(ip.ip_address).version
|
|
for ip in port.fixed_ips])
|
|
for opt in port.extra_dhcp_opts:
|
|
if opt.opt_name in (edo_ext.DHCP_OPT_CLIENT_ID,
|
|
DHCP_OPT_CLIENT_ID_NUM,
|
|
str(DHCP_OPT_CLIENT_ID_NUM)):
|
|
continue
|
|
opt_ip_version = opt.ip_version
|
|
if opt_ip_version in port_ip_versions:
|
|
options.append(
|
|
self._format_option(
|
|
opt_ip_version,
|
|
self._PORT_TAG_PREFIX % port.id,
|
|
opt.opt_name, opt.opt_value))
|
|
else:
|
|
LOG.info("Cannot apply dhcp option %(opt)s "
|
|
"because it's ip_version %(version)d "
|
|
"is not in port's address IP versions",
|
|
{'opt': opt.opt_name,
|
|
'version': opt_ip_version})
|
|
|
|
# provides all dnsmasq ip as dns-server if there is more than
|
|
# one dnsmasq for a subnet and there is no dns-server submitted
|
|
# by the server
|
|
if port.device_owner == constants.DEVICE_OWNER_DHCP:
|
|
for ip in port.fixed_ips:
|
|
if ip.subnet_id not in subnets_without_nameservers:
|
|
continue
|
|
dhcp_ips[ip.subnet_id].append(ip.ip_address)
|
|
|
|
for subnet_id, ips in dhcp_ips.items():
|
|
for ip_version in (4, 6):
|
|
vx_ips = [ip for ip in ips
|
|
if netaddr.IPAddress(ip).version == ip_version]
|
|
if len(vx_ips) > 1:
|
|
options.append(
|
|
self._format_option(
|
|
ip_version, self._SUBNET_TAG_PREFIX % subnet_id,
|
|
'dns-server',
|
|
','.join(
|
|
Dnsmasq._convert_to_literal_addrs(ip_version,
|
|
vx_ips))))
|
|
return options
|
|
|
|
def _make_subnet_interface_ip_map(self):
|
|
ip_dev = ip_lib.IPDevice(self.interface_name,
|
|
namespace=self.network.namespace)
|
|
|
|
subnet_lookup = dict(
|
|
(netaddr.IPNetwork(subnet.cidr), subnet.id)
|
|
for subnet in self.network.subnets
|
|
)
|
|
|
|
retval = {}
|
|
|
|
for addr in ip_dev.addr.list():
|
|
ip_net = netaddr.IPNetwork(addr['cidr'])
|
|
|
|
if ip_net in subnet_lookup:
|
|
retval[subnet_lookup[ip_net]] = addr['cidr'].split('/')[0]
|
|
|
|
return retval
|
|
|
|
def _format_option(self, ip_version, tag, option, *args):
|
|
"""Format DHCP option by option name or code."""
|
|
option = str(option)
|
|
pattern = "(tag:(.*),)?(.*)$"
|
|
matches = re.match(pattern, option)
|
|
extra_tag = matches.groups()[0]
|
|
option = matches.groups()[2]
|
|
|
|
if not option.isdigit():
|
|
if ip_version == 4:
|
|
option = 'option:%s' % option
|
|
else:
|
|
option = 'option6:%s' % option
|
|
if extra_tag:
|
|
tags = ('tag:' + tag, extra_tag[:-1], '%s' % option)
|
|
else:
|
|
tags = ('tag:' + tag, '%s' % option)
|
|
return ','.join(tags + args)
|
|
|
|
@staticmethod
|
|
def _convert_to_literal_addrs(ip_version, ips):
|
|
if ip_version == 4:
|
|
return ips
|
|
return ['[' + ip + ']' for ip in ips]
|
|
|
|
@classmethod
|
|
def get_isolated_subnets(cls, network):
|
|
"""Returns a dict indicating whether or not a subnet is isolated
|
|
|
|
A subnet is considered non-isolated if there is a port connected to
|
|
the subnet, and the port's ip address matches that of the subnet's
|
|
gateway. The port must be owned by a neutron router.
|
|
"""
|
|
isolated_subnets = collections.defaultdict(lambda: True)
|
|
all_subnets = cls._get_all_subnets(network)
|
|
subnets = dict((subnet.id, subnet) for subnet in all_subnets)
|
|
|
|
for port in network.ports:
|
|
if port.device_owner not in constants.ROUTER_INTERFACE_OWNERS:
|
|
continue
|
|
for alloc in port.fixed_ips:
|
|
if (alloc.subnet_id in subnets and
|
|
subnets[alloc.subnet_id].gateway_ip ==
|
|
alloc.ip_address):
|
|
isolated_subnets[alloc.subnet_id] = False
|
|
|
|
return isolated_subnets
|
|
|
|
@staticmethod
|
|
def has_metadata_subnet(subnets):
|
|
"""Check if the subnets has a metadata subnet."""
|
|
meta_cidr = netaddr.IPNetwork(METADATA_DEFAULT_CIDR)
|
|
if any(netaddr.IPNetwork(s.cidr) in meta_cidr
|
|
for s in subnets):
|
|
return True
|
|
return False
|
|
|
|
@classmethod
|
|
def should_enable_metadata(cls, conf, network):
|
|
"""Determine whether the metadata proxy is needed for a network
|
|
|
|
This method returns True for truly isolated networks (ie: not attached
|
|
to a router) when enable_isolated_metadata is True, or for all the
|
|
networks when the force_metadata flags is True.
|
|
|
|
This method also returns True when enable_metadata_network is True,
|
|
and the network passed as a parameter has a subnet in the link-local
|
|
CIDR, thus characterizing it as a "metadata" network. The metadata
|
|
network is used by solutions which do not leverage the l3 agent for
|
|
providing access to the metadata service via logical routers built
|
|
with 3rd party backends.
|
|
"""
|
|
# Only IPv4 subnets, with dhcp enabled, will use the metadata proxy.
|
|
all_subnets = cls._get_all_subnets(network)
|
|
v4_dhcp_subnets = [s for s in all_subnets
|
|
if s.ip_version == 4 and s.enable_dhcp]
|
|
if not v4_dhcp_subnets:
|
|
return False
|
|
|
|
if conf.force_metadata:
|
|
return True
|
|
|
|
if not conf.enable_isolated_metadata:
|
|
return False
|
|
|
|
if (conf.enable_metadata_network and
|
|
cls.has_metadata_subnet(all_subnets)):
|
|
return True
|
|
|
|
isolated_subnets = cls.get_isolated_subnets(network)
|
|
return any(isolated_subnets[s.id] for s in v4_dhcp_subnets)
|
|
|
|
|
|
class DeviceManager(object):
|
|
|
|
def __init__(self, conf, plugin):
|
|
self.conf = conf
|
|
self.plugin = plugin
|
|
self.driver = agent_common_utils.load_interface_driver(conf)
|
|
|
|
def get_interface_name(self, network, port):
|
|
"""Return interface(device) name for use by the DHCP process."""
|
|
return self.driver.get_device_name(port)
|
|
|
|
def get_device_id(self, network):
|
|
"""Return a unique DHCP device ID for this host on the network."""
|
|
# There could be more than one dhcp server per network, so create
|
|
# a device id that combines host and network ids
|
|
return common_utils.get_dhcp_agent_device_id(network.id,
|
|
self.conf.host)
|
|
|
|
def _set_default_route_ip_version(self, network, device_name, ip_version):
|
|
device = ip_lib.IPDevice(device_name, namespace=network.namespace)
|
|
gateway = device.route.get_gateway(ip_version=ip_version)
|
|
if gateway:
|
|
gateway = gateway.get('gateway')
|
|
|
|
for subnet in network.subnets:
|
|
skip_subnet = (
|
|
subnet.ip_version != ip_version or
|
|
not subnet.enable_dhcp or
|
|
subnet.gateway_ip is None)
|
|
|
|
if skip_subnet:
|
|
continue
|
|
|
|
if subnet.ip_version == constants.IP_VERSION_6:
|
|
# This is duplicating some of the API checks already done,
|
|
# but some of the functional tests call directly
|
|
prefixlen = netaddr.IPNetwork(subnet.cidr).prefixlen
|
|
if prefixlen == 0 or prefixlen > 126:
|
|
continue
|
|
modes = [constants.IPV6_SLAAC, constants.DHCPV6_STATELESS]
|
|
addr_mode = getattr(subnet, 'ipv6_address_mode', None)
|
|
ra_mode = getattr(subnet, 'ipv6_ra_mode', None)
|
|
if (prefixlen != 64 and
|
|
(addr_mode in modes or ra_mode in modes)):
|
|
continue
|
|
|
|
if gateway != subnet.gateway_ip:
|
|
LOG.debug('Setting IPv%(version)s gateway for dhcp netns '
|
|
'on net %(n)s to %(ip)s',
|
|
{'n': network.id, 'ip': subnet.gateway_ip,
|
|
'version': ip_version})
|
|
|
|
# Check for and remove the on-link route for the old
|
|
# gateway being replaced, if it is outside the subnet
|
|
is_old_gateway_not_in_subnet = (gateway and
|
|
not ipam_utils.check_subnet_ip(
|
|
subnet.cidr, gateway))
|
|
if is_old_gateway_not_in_subnet:
|
|
onlink = device.route.list_onlink_routes(ip_version)
|
|
existing_onlink_routes = set(r['cidr'] for r in onlink)
|
|
if gateway in existing_onlink_routes:
|
|
device.route.delete_route(gateway, scope='link')
|
|
|
|
is_new_gateway_not_in_subnet = (subnet.gateway_ip and
|
|
not ipam_utils.check_subnet_ip(
|
|
subnet.cidr,
|
|
subnet.gateway_ip))
|
|
if is_new_gateway_not_in_subnet:
|
|
device.route.add_route(subnet.gateway_ip, scope='link')
|
|
device.route.add_gateway(subnet.gateway_ip)
|
|
|
|
return
|
|
|
|
# No subnets on the network have a valid gateway. Clean it up to avoid
|
|
# confusion from seeing an invalid gateway here.
|
|
if gateway is not None:
|
|
LOG.debug('Removing IPv%(version)s gateway for dhcp netns on '
|
|
'net %(n)s',
|
|
{'n': network.id, 'version': ip_version})
|
|
|
|
device.route.delete_gateway(gateway)
|
|
|
|
def _set_default_route(self, network, device_name):
|
|
"""Sets the default gateway for this dhcp namespace.
|
|
|
|
This method is idempotent and will only adjust the route if adjusting
|
|
it would change it from what it already is. This makes it safe to call
|
|
and avoids unnecessary perturbation of the system.
|
|
"""
|
|
for ip_version in (constants.IP_VERSION_4, constants.IP_VERSION_6):
|
|
self._set_default_route_ip_version(network, device_name,
|
|
ip_version)
|
|
|
|
def _setup_existing_dhcp_port(self, network, device_id, dhcp_subnets):
|
|
"""Set up the existing DHCP port, if there is one."""
|
|
|
|
# To avoid pylint thinking that port might be undefined after
|
|
# the following loop...
|
|
port = None
|
|
|
|
# Look for an existing DHCP port for this network.
|
|
for port in network.ports:
|
|
port_device_id = getattr(port, 'device_id', None)
|
|
if port_device_id == device_id:
|
|
# If using gateway IPs on this port, we can skip the
|
|
# following code, whose purpose is just to review and
|
|
# update the Neutron-allocated IP addresses for the
|
|
# port.
|
|
if self.driver.use_gateway_ips:
|
|
return port
|
|
# Otherwise break out, as we now have the DHCP port
|
|
# whose subnets and addresses we need to review.
|
|
break
|
|
else:
|
|
return None
|
|
|
|
# Compare what the subnets should be against what is already
|
|
# on the port.
|
|
dhcp_enabled_subnet_ids = set(dhcp_subnets)
|
|
port_subnet_ids = set(ip.subnet_id for ip in port.fixed_ips)
|
|
|
|
# If those differ, we need to call update.
|
|
if dhcp_enabled_subnet_ids != port_subnet_ids:
|
|
# Collect the subnets and fixed IPs that the port already
|
|
# has, for subnets that are still in the DHCP-enabled set.
|
|
wanted_fixed_ips = []
|
|
for fixed_ip in port.fixed_ips:
|
|
if fixed_ip.subnet_id in dhcp_enabled_subnet_ids:
|
|
wanted_fixed_ips.append(
|
|
{'subnet_id': fixed_ip.subnet_id,
|
|
'ip_address': fixed_ip.ip_address})
|
|
|
|
# Add subnet IDs for new DHCP-enabled subnets.
|
|
wanted_fixed_ips.extend(
|
|
dict(subnet_id=s)
|
|
for s in dhcp_enabled_subnet_ids - port_subnet_ids)
|
|
|
|
# Update the port to have the calculated subnets and fixed
|
|
# IPs. The Neutron server will allocate a fresh IP for
|
|
# each subnet that doesn't already have one.
|
|
port = self.plugin.update_dhcp_port(
|
|
port.id,
|
|
{'port': {'network_id': network.id,
|
|
'fixed_ips': wanted_fixed_ips}})
|
|
if not port:
|
|
raise exceptions.Conflict()
|
|
|
|
return port
|
|
|
|
def _setup_reserved_dhcp_port(self, network, device_id, dhcp_subnets):
|
|
"""Setup the reserved DHCP port, if there is one."""
|
|
LOG.debug('DHCP port %(device_id)s on network %(network_id)s'
|
|
' does not yet exist. Checking for a reserved port.',
|
|
{'device_id': device_id, 'network_id': network.id})
|
|
for port in network.ports:
|
|
port_device_id = getattr(port, 'device_id', None)
|
|
if port_device_id == constants.DEVICE_ID_RESERVED_DHCP_PORT:
|
|
port = self.plugin.update_dhcp_port(
|
|
port.id, {'port': {'network_id': network.id,
|
|
'device_id': device_id}})
|
|
if port:
|
|
return port
|
|
|
|
def _setup_new_dhcp_port(self, network, device_id, dhcp_subnets):
|
|
"""Create and set up new DHCP port for the specified network."""
|
|
LOG.debug('DHCP port %(device_id)s on network %(network_id)s'
|
|
' does not yet exist. Creating new one.',
|
|
{'device_id': device_id, 'network_id': network.id})
|
|
|
|
# Make a list of the subnets that need a unique IP address for
|
|
# this DHCP port.
|
|
if self.driver.use_gateway_ips:
|
|
unique_ip_subnets = []
|
|
else:
|
|
unique_ip_subnets = [dict(subnet_id=s) for s in dhcp_subnets]
|
|
|
|
port_dict = dict(
|
|
name='',
|
|
admin_state_up=True,
|
|
device_id=device_id,
|
|
network_id=network.id,
|
|
tenant_id=network.tenant_id,
|
|
fixed_ips=unique_ip_subnets)
|
|
return self.plugin.create_dhcp_port({'port': port_dict})
|
|
|
|
def setup_dhcp_port(self, network):
|
|
"""Create/update DHCP port for the host if needed and return port."""
|
|
|
|
# The ID that the DHCP port will have (or already has).
|
|
device_id = self.get_device_id(network)
|
|
|
|
# Get the set of DHCP-enabled local subnets on this network.
|
|
dhcp_subnets = {subnet.id: subnet for subnet in network.subnets
|
|
if subnet.enable_dhcp}
|
|
|
|
# There are 3 cases: either the DHCP port already exists (but
|
|
# might need to be updated for a changed set of subnets); or
|
|
# some other code has already prepared a 'reserved' DHCP port,
|
|
# and we just need to adopt that; or we need to create a new
|
|
# DHCP port. Try each of those in turn until we have a DHCP
|
|
# port.
|
|
for setup_method in (self._setup_existing_dhcp_port,
|
|
self._setup_reserved_dhcp_port,
|
|
self._setup_new_dhcp_port):
|
|
dhcp_port = setup_method(network, device_id, dhcp_subnets)
|
|
if dhcp_port:
|
|
break
|
|
else:
|
|
raise exceptions.Conflict()
|
|
|
|
# FIXME(kevinbenton): ensure we have the IPs we actually need.
|
|
# can be removed once bug/1627480 is fixed
|
|
if not self.driver.use_gateway_ips:
|
|
expected = set(dhcp_subnets)
|
|
actual = {fip.subnet_id for fip in dhcp_port.fixed_ips}
|
|
missing = expected - actual
|
|
if missing:
|
|
LOG.debug("Requested DHCP port with IPs on subnets "
|
|
"%(expected)s but only got IPs on subnets "
|
|
"%(actual)s.", {'expected': expected,
|
|
'actual': actual})
|
|
raise exceptions.SubnetMismatchForPort(
|
|
port_id=dhcp_port.id, subnet_id=list(missing)[0])
|
|
# Convert subnet_id to subnet dict
|
|
fixed_ips = [dict(subnet_id=fixed_ip.subnet_id,
|
|
ip_address=fixed_ip.ip_address,
|
|
subnet=dhcp_subnets[fixed_ip.subnet_id])
|
|
for fixed_ip in dhcp_port.fixed_ips
|
|
# we don't care about any ips on subnets irrelevant
|
|
# to us (e.g. auto ipv6 addresses)
|
|
if fixed_ip.subnet_id in dhcp_subnets]
|
|
|
|
ips = [DictModel(item) if isinstance(item, dict) else item
|
|
for item in fixed_ips]
|
|
dhcp_port.fixed_ips = ips
|
|
|
|
return dhcp_port
|
|
|
|
def _update_dhcp_port(self, network, port):
|
|
for index in range(len(network.ports)):
|
|
if network.ports[index].id == port.id:
|
|
network.ports[index] = port
|
|
break
|
|
else:
|
|
network.ports.append(port)
|
|
|
|
def _cleanup_stale_devices(self, network, dhcp_port):
|
|
"""Unplug any devices found in the namespace except for dhcp_port."""
|
|
LOG.debug("Cleaning stale devices for network %s", network.id)
|
|
skip_dev_name = (self.driver.get_device_name(dhcp_port)
|
|
if dhcp_port else None)
|
|
ns_ip = ip_lib.IPWrapper(namespace=network.namespace)
|
|
if not ns_ip.netns.exists(network.namespace):
|
|
return
|
|
for d in ns_ip.get_devices():
|
|
# delete all devices except current active DHCP port device
|
|
if d.name != skip_dev_name:
|
|
LOG.debug("Found stale device %s, deleting", d.name)
|
|
try:
|
|
self.unplug(d.name, network)
|
|
except Exception:
|
|
LOG.exception("Exception during stale "
|
|
"dhcp device cleanup")
|
|
|
|
def plug(self, network, port, interface_name):
|
|
"""Plug device settings for the network's DHCP on this host."""
|
|
self.driver.plug(network.id,
|
|
port.id,
|
|
interface_name,
|
|
port.mac_address,
|
|
namespace=network.namespace,
|
|
mtu=network.get('mtu'))
|
|
|
|
def setup(self, network):
|
|
"""Create and initialize a device for network's DHCP on this host."""
|
|
try:
|
|
port = self.setup_dhcp_port(network)
|
|
except Exception:
|
|
with excutils.save_and_reraise_exception():
|
|
# clear everything out so we don't leave dangling interfaces
|
|
# if setup never succeeds in the future.
|
|
self._cleanup_stale_devices(network, dhcp_port=None)
|
|
self._update_dhcp_port(network, port)
|
|
interface_name = self.get_interface_name(network, port)
|
|
|
|
# Disable acceptance of RAs in the namespace so we don't
|
|
# auto-configure an IPv6 address since we explicitly configure
|
|
# them on the device. This must be done before any interfaces
|
|
# are plugged since it could receive an RA by the time
|
|
# plug() returns, so we have to create the namespace first.
|
|
# It must also be done in the case there is an existing IPv6
|
|
# address here created via SLAAC, since it will be deleted
|
|
# and added back statically in the call to init_l3() below.
|
|
if network.namespace:
|
|
ip_lib.IPWrapper().ensure_namespace(network.namespace)
|
|
ip_lib.set_ip_nonlocal_bind_for_namespace(network.namespace, 1,
|
|
root_namespace=True)
|
|
if ipv6_utils.is_enabled_and_bind_by_default():
|
|
self.driver.configure_ipv6_ra(network.namespace, 'default',
|
|
n_const.ACCEPT_RA_DISABLED)
|
|
|
|
if ip_lib.ensure_device_is_ready(interface_name,
|
|
namespace=network.namespace):
|
|
LOG.debug('Reusing existing device: %s.', interface_name)
|
|
# force mtu on the port for in case it was changed for the network
|
|
mtu = getattr(network, 'mtu', 0)
|
|
if mtu:
|
|
self.driver.set_mtu(interface_name, mtu,
|
|
namespace=network.namespace)
|
|
else:
|
|
try:
|
|
self.plug(network, port, interface_name)
|
|
except Exception:
|
|
with excutils.save_and_reraise_exception():
|
|
LOG.exception('Unable to plug DHCP port for '
|
|
'network %s. Releasing port.',
|
|
network.id)
|
|
# We should unplug the interface in bridge side.
|
|
self.unplug(interface_name, network)
|
|
self.plugin.release_dhcp_port(network.id, port.device_id)
|
|
|
|
self.fill_dhcp_udp_checksums(namespace=network.namespace)
|
|
ip_cidrs = []
|
|
for fixed_ip in port.fixed_ips:
|
|
subnet = fixed_ip.subnet
|
|
net = netaddr.IPNetwork(subnet.cidr)
|
|
ip_cidr = '%s/%s' % (fixed_ip.ip_address, net.prefixlen)
|
|
ip_cidrs.append(ip_cidr)
|
|
|
|
if self.driver.use_gateway_ips:
|
|
# For each DHCP-enabled subnet, add that subnet's gateway
|
|
# IP address to the Linux device for the DHCP port.
|
|
for subnet in network.subnets:
|
|
if not subnet.enable_dhcp:
|
|
continue
|
|
gateway = subnet.gateway_ip
|
|
if gateway:
|
|
net = netaddr.IPNetwork(subnet.cidr)
|
|
ip_cidrs.append('%s/%s' % (gateway, net.prefixlen))
|
|
|
|
if self.conf.force_metadata or self.conf.enable_isolated_metadata:
|
|
ip_cidrs.append(METADATA_DEFAULT_CIDR)
|
|
|
|
self.driver.init_l3(interface_name, ip_cidrs,
|
|
namespace=network.namespace)
|
|
|
|
self._set_default_route(network, interface_name)
|
|
self._cleanup_stale_devices(network, port)
|
|
|
|
return interface_name
|
|
|
|
def update(self, network, device_name):
|
|
"""Update device settings for the network's DHCP on this host."""
|
|
self._set_default_route(network, device_name)
|
|
|
|
def unplug(self, device_name, network):
|
|
"""Unplug device settings for the network's DHCP on this host."""
|
|
self.driver.unplug(device_name, namespace=network.namespace)
|
|
|
|
def destroy(self, network, device_name):
|
|
"""Destroy the device used for the network's DHCP on this host."""
|
|
if device_name:
|
|
self.unplug(device_name, network)
|
|
else:
|
|
LOG.debug('No interface exists for network %s', network.id)
|
|
|
|
self.plugin.release_dhcp_port(network.id,
|
|
self.get_device_id(network))
|
|
|
|
def fill_dhcp_udp_checksums(self, namespace):
|
|
"""Ensure DHCP reply packets always have correct UDP checksums."""
|
|
iptables_mgr = iptables_manager.IptablesManager(use_ipv6=True,
|
|
namespace=namespace)
|
|
ipv4_rule = ('-p udp -m udp --dport %d -j CHECKSUM --checksum-fill'
|
|
% constants.DHCP_RESPONSE_PORT)
|
|
ipv6_rule = ('-p udp -m udp --dport %d -j CHECKSUM --checksum-fill'
|
|
% n_const.DHCPV6_CLIENT_PORT)
|
|
iptables_mgr.ipv4['mangle'].add_rule('POSTROUTING', ipv4_rule)
|
|
iptables_mgr.ipv6['mangle'].add_rule('POSTROUTING', ipv6_rule)
|
|
iptables_mgr.apply()
|