diff --git a/neutron/agent/linux/dhcp.py b/neutron/agent/linux/dhcp.py index 99702a32c02..74118ee3abf 100644 --- a/neutron/agent/linux/dhcp.py +++ b/neutron/agent/linux/dhcp.py @@ -607,7 +607,89 @@ class Dnsmasq(DhcpLocalProcess): constants.DHCPV6_STATELESS))), reverse=True) - def _iter_hosts(self): + def _merge_alloc_addr6_list(self, fixed_ips, v6_nets): + """Merge fixed_ips to ipv6 addr lists + + If a port have multiple IPv6 addresses in the same subnet, merge the + into one entry listing all the addresess, creating a single dhcp-host + entry with the list of addresses defined allow dnsmasq to make all + addresses available as requests for leases arrive. + + See dnsmasq-discuss mailing list: http://lists.thekelleys.org.uk/ + pipermail/dnsmasq-discuss/2020q1/013743.html + + """ + by_subnet = {} + NewFip = collections.namedtuple('NewFip', 'subnet_id ip_address') + merged = [] + + for fip in fixed_ips: + if (fip.subnet_id in v6_nets and + v6_nets[fip.subnet_id].ipv6_address_mode == ( + constants.DHCPV6_STATEFUL)): + if fip.subnet_id not in by_subnet: + by_subnet.update({fip.subnet_id: []}) + by_subnet[fip.subnet_id].append(fip.ip_address) + else: + merged.append(fip) + + for subnet_id in by_subnet: + addr6_list = ','.join([self._format_address_for_dnsmasq(ip) + for ip in by_subnet[subnet_id]]) + merged.append(NewFip(subnet_id=subnet_id, + ip_address=addr6_list)) + + return merged + + def _get_dns_assignment(self, ip_address, dns_assignment): + """Get DNS assignment hostname and fqdn + + In dnsmasq it is not possible to configure two dhcp-host + entries mapped to a single client mac address with IP + addresses in the same subnet. When recieving a requst + dnsmasq will match on the first entry in it's config, + and lease that address. The second entry will never be + used. + + For IPv6 it is possible to add multiple IPv6 addresses + to a single dhcp-host entry by placing a list of addresses + in brackets, i.e [addr1][addr2][...]. See dnsmasq mailing + list: http://lists.thekelleys.org.uk/pipermail/ + dnsmasq-discuss/2020q1/013671.html. Since we cannot have + two hostnames in the dhcp-host entry this method picks the + first hostname and fqdn it find's matching one of the IP's + in the fixed-ips in dns_assignment or the hostname is + generated based on the first fixed-ip. + + :param ip_address: IP address or a list of IPv6 addresses + :param dns_ip_map: DNS IP Mapping + :param dns_assignment: DNS assignments + :return: hostname, fqdn + """ + hostname, fqdn = None, None + ip_addresses = ip_address.replace('[', '').split(']') + + if dns_assignment: + dns_ip_map = {d.ip_address: d for d in dns_assignment} + for addr in ip_addresses: + # 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 addr in dns_ip_map: + hostname = dns_ip_map[addr].hostname + fqdn = dns_ip_map[addr].fqdn + break + + if hostname is None: + hostname = ('host-%s' % + ip_addresses[0].replace('.', '-').replace(':', '-')) + fqdn = hostname + if self.conf.dns_domain: + fqdn = '%s.%s' % (fqdn, self.conf.dns_domain) + + return hostname, fqdn + + def _iter_hosts(self, merge_addr6_list=False): """Iterate over hosts. For each host on the network we yield a tuple containing: @@ -630,11 +712,13 @@ class Dnsmasq(DhcpLocalProcess): for port in self.network.ports: fixed_ips = self._sort_fixed_ips_for_dnsmasq(port.fixed_ips, v6_nets) + # TODO(hjensas): Drop this conditional and option once distros + # generally have dnsmasq supporting addr6 list and range. + if self.conf.dnsmasq_enable_addr6_list and merge_addr6_list: + fixed_ips = self._merge_alloc_addr6_list(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 @@ -646,18 +730,9 @@ class Dnsmasq(DhcpLocalProcess): # 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) + hostname, fqdn = self._get_dns_assignment(alloc.ip_address, + dns_assignment) + yield (port, alloc, hostname, fqdn, no_dhcp, no_opts) def _get_port_extra_dhcp_opts(self, port): @@ -742,7 +817,7 @@ class Dnsmasq(DhcpLocalProcess): 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(): + for host_tuple in self._iter_hosts(merge_addr6_list=True): 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): diff --git a/neutron/cmd/sanity/checks.py b/neutron/cmd/sanity/checks.py index 14dce508327..2748e6889f5 100644 --- a/neutron/cmd/sanity/checks.py +++ b/neutron/cmd/sanity/checks.py @@ -44,6 +44,7 @@ LOG = logging.getLogger(__name__) MINIMUM_DNSMASQ_VERSION = '2.67' DNSMASQ_VERSION_DHCP_RELEASE6 = '2.76' +DNSMASQ_VERSION_HOST_ADDR6_LIST = '2.81' DIRECT_PORT_QOS_MIN_OVS_VERSION = '2.11' MINIMUM_DIBBLER_VERSION = '1.0.1' CONNTRACK_GRE_MODULE = 'nf_conntrack_proto_gre' @@ -202,6 +203,10 @@ def get_dnsmasq_version_with_dhcp_release6(): return DNSMASQ_VERSION_DHCP_RELEASE6 +def get_dnsmasq_version_with_host_addr6_list(): + return DNSMASQ_VERSION_HOST_ADDR6_LIST + + def get_ovs_version_for_qos_direct_port_support(): return DIRECT_PORT_QOS_MIN_OVS_VERSION @@ -230,6 +235,18 @@ def dnsmasq_version_supported(): ver = distutils.version.StrictVersion(m.group(1) if m else '0.0') if ver < distutils.version.StrictVersion(MINIMUM_DNSMASQ_VERSION): return False + if (cfg.CONF.dnsmasq_enable_addr6_list is True and + ver < distutils.version.StrictVersion( + DNSMASQ_VERSION_HOST_ADDR6_LIST)): + LOG.warning('Support for multiple IPv6 addresses in host ' + 'entries was introduced in dnsmasq version ' + '%(required)s. Found dnsmasq version %(current)s, ' + 'which does not support this feature. Unless support ' + 'for multiple IPv6 addresses was backported to the ' + 'running build of dnsmasq, the configuration option ' + 'dnsmasq_enable_addr6_list should be set to False.', + {'required': DNSMASQ_VERSION_HOST_ADDR6_LIST, + 'current': ver}) except (OSError, RuntimeError, IndexError, ValueError) as e: LOG.debug("Exception while checking minimal dnsmasq version. " "Exception: %s", e) diff --git a/neutron/conf/agent/dhcp.py b/neutron/conf/agent/dhcp.py index e7254d1544c..8bf1bbe4831 100644 --- a/neutron/conf/agent/dhcp.py +++ b/neutron/conf/agent/dhcp.py @@ -116,6 +116,9 @@ DNSMASQ_OPTS = [ cfg.IntOpt('dhcp_rebinding_time', default=0, help=_("DHCP rebinding time T2 (in seconds). If set to 0, it " "will default to 7/8 of the lease time.")), + cfg.BoolOpt('dnsmasq_enable_addr6_list', default=False, + help=_("Enable dhcp-host entry with list of addresses when " + "port has multiple IPv6 addresses in the same subnet.")) ] diff --git a/neutron/tests/unit/agent/linux/test_dhcp.py b/neutron/tests/unit/agent/linux/test_dhcp.py index ab9cd205f87..5e3a9ee831c 100644 --- a/neutron/tests/unit/agent/linux/test_dhcp.py +++ b/neutron/tests/unit/agent/linux/test_dhcp.py @@ -231,6 +231,25 @@ class FakeV6PortExtraOpt(object): ip_version=constants.IP_VERSION_6)] +class FakeV6PortMultipleFixedIpsSameSubnet(object): + def __init__(self, domain='openstacklocal'): + self.id = 'hhhhhhhh-hhhh-hhhh-hhhh-hhhhhhhhhhhh' + self.admin_state_up = True + self.device_owner = 'foo3' + self.fixed_ips = [ + FakeIPAllocation('fdca:3ba5:a17a:4ba3::2', + 'ffffffff-ffff-ffff-ffff-ffffffffffff'), + FakeIPAllocation('fdca:3ba5:a17a:4ba3::4', + 'ffffffff-ffff-ffff-ffff-ffffffffffff')] + self.mac_address = '00:00:f3:aa:bb:cc' + self.device_id = 'fake_port6' + self.extra_dhcp_opts = [] + self.dns_assignment = [FakeDNSAssignment('fdca:3ba5:a17a:4ba3::2', + domain=domain), + FakeDNSAssignment('fdca:3ba5:a17a:4ba3::4', + domain=domain)] + + class FakeDualPortWithV6ExtraOpt(object): def __init__(self): self.id = 'hhhhhhhh-hhhh-hhhh-hhhh-hhhhhhhhhhhh' @@ -1007,6 +1026,14 @@ class FakeNetworkWithV6SatelessAndV4DHCPSubnets(object): self.namespace = 'qdhcp-ns' +class FakeV6NetworkStatefulDHCPSameSubnetFixedIps(object): + def __init__(self): + self.id = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb' + self.subnets = [FakeV6SubnetDHCPStateful()] + self.ports = [FakeV6PortMultipleFixedIpsSameSubnet()] + self.namespace = 'qdhcp-ns' + + class LocalChild(dhcp.DhcpLocalProcess): PORTS = {4: [4], 6: [6]} @@ -2765,6 +2792,23 @@ class TestDnsmasq(TestBase): self.safe.assert_has_calls([mock.call(exp_host_name, exp_host_data), mock.call(exp_opt_name, exp_opt_data)]) + def test_host_and_opts_file_on_stateful_dhcpv6_same_subnet_fixedips(self): + self.conf.set_override('dnsmasq_enable_addr6_list', True) + exp_host_name = '/dhcp/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/host' + exp_host_data = ( + '00:00:f3:aa:bb:cc,host-fdca-3ba5-a17a-4ba3--2.openstacklocal.,' + '[fdca:3ba5:a17a:4ba3::2],[fdca:3ba5:a17a:4ba3::4]\n'.lstrip()) + exp_opt_name = '/dhcp/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/opts' + exp_opt_data = ('tag:subnet-ffffffff-ffff-ffff-ffff-ffffffffffff,' + 'option6:dns-server,[2001:0200:feed:7ac0::1]\n' + 'tag:subnet-ffffffff-ffff-ffff-ffff-ffffffffffff,' + 'option6:domain-search,openstacklocal').lstrip() + dm = self._get_dnsmasq(FakeV6NetworkStatefulDHCPSameSubnetFixedIps()) + dm._output_hosts_file() + dm._output_opts_file() + self.safe.assert_has_calls([mock.call(exp_host_name, exp_host_data), + mock.call(exp_opt_name, exp_opt_data)]) + def test_host_and_opts_file_on_stateless_dhcpv6_network_no_dns(self): exp_host_name = '/dhcp/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/host' exp_opt_name = '/dhcp/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/opts' diff --git a/releasenotes/notes/dhcp-dnsmasq-dhcp-host-addr6-list-support-45d104b3f7ce220e.yaml b/releasenotes/notes/dhcp-dnsmasq-dhcp-host-addr6-list-support-45d104b3f7ce220e.yaml new file mode 100644 index 00000000000..f96201b62bc --- /dev/null +++ b/releasenotes/notes/dhcp-dnsmasq-dhcp-host-addr6-list-support-45d104b3f7ce220e.yaml @@ -0,0 +1,25 @@ +--- +features: + - | + Adds support for configuring a list of IPv6 addresses for a dhcp-host entry + in the dnsmasq DHCP agent driver. For a port with multiple IPv6 fixed-ips + in the same subnet a single dhcp-host entry including all the addresses are + written to the dnsmasq dhcp-hostsfile. + + Reserving multiple addresses for a host eases problems related to network + and chain-booting where each step in the boot process requests an address + using different DUID/IAID combinations. With a single address, only one + gets the "static" address and the boot process will fail on the following + steps. By reserving enough addresses for all the stages of the boot process + this problem is resolved. (See bug: + `#1861032 `_) + + .. NOTE:: This requires dnsmasq version 2.81 or later. Some distributions + may backport this feauture to earlier dnsmasq version as part of + the packaging, check the distributions releasenotes. + + Since the new configuration format is invalid in previous versions + of dnsmasq this feauture is *disabled* by default. To *enable* the + feature set the option ``dnsmasq_enable_addr6_list`` in DHCP agent + configuration to ``True``. +