Browse Source

DHCPv6 - Use addr6_list in dnsmasq

Adds a new bool option dnsmasq_enable_addr6_list, when
enabled configuration for dnsmasq will be created with a
single dhcp-host entry specifying a list of ip addresses
allocated for a port.

Previously the dnsmasq dhcp-agent driver would write a
separate dhcp-host entry for each fixed-ip of a port in
the dnsmasq hosts file. The result of the previous
behaviour is that dnsmasq will only use one of the config
entries, i.e the first one matching the mac identifier.

The trade-off is that only a single dns_assignment will
be used for IPv6 addresses within the same subnet. (But
in practice, this was always the case since only the
first config entry would be used by dnsmasq.)

Why is this neccecary:
  This is done to enable ironic provisioning over IPv6
  using DHCPv6-stateful. For background info, please
  read dnsmasq-discuss thread:

Closes-Bug: #1861032
Change-Id: I833840e7daed2efa7efaece27cfd1ba28e0feb90
Harald Jensås 1 year ago
5 changed files with 180 additions and 16 deletions
  1. +91
  2. +17
  3. +3
  4. +44
  5. +25

+ 91
- 16
neutron/agent/linux/ View File

@ -607,7 +607,89 @@ class Dnsmasq(DhcpLocalProcess):
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:
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 == (
if fip.subnet_id not in by_subnet:
by_subnet.update({fip.subnet_id: []})
for subnet_id in by_subnet:
addr6_list = ','.join([self._format_address_for_dnsmasq(ip)
for ip in by_subnet[subnet_id]])
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
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
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
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
fixed_ips = self._sort_fixed_ips_for_dnsmasq(port.fixed_ips,
# 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
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,
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):

+ 17
- 0
neutron/cmd/sanity/ View File

@ -44,6 +44,7 @@ LOG = logging.getLogger(__name__)
CONNTRACK_GRE_MODULE = 'nf_conntrack_proto_gre'
@ -202,6 +203,10 @@ def get_dnsmasq_version_with_dhcp_release6():
def get_dnsmasq_version_with_host_addr6_list():
def get_ovs_version_for_qos_direct_port_support():
@ -230,6 +235,18 @@ def dnsmasq_version_supported():
ver = distutils.version.StrictVersion( 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(
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.',
'current': ver})
except (OSError, RuntimeError, IndexError, ValueError) as e:
LOG.debug("Exception while checking minimal dnsmasq version. "
"Exception: %s", e)

+ 3
- 0
neutron/conf/agent/ View File

@ -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."))

+ 44
- 0
neutron/tests/unit/agent/linux/ View File

@ -231,6 +231,25 @@ class FakeV6PortExtraOpt(object):
class FakeV6PortMultipleFixedIpsSameSubnet(object):
def __init__(self, domain='openstacklocal'): = 'hhhhhhhh-hhhh-hhhh-hhhh-hhhhhhhhhhhh'
self.admin_state_up = True
self.device_owner = 'foo3'
self.fixed_ips = [
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',
class FakeDualPortWithV6ExtraOpt(object):
def __init__(self): = 'hhhhhhhh-hhhh-hhhh-hhhh-hhhhhhhhhhhh'
@ -1007,6 +1026,14 @@ class FakeNetworkWithV6SatelessAndV4DHCPSubnets(object):
self.namespace = 'qdhcp-ns'
class FakeV6NetworkStatefulDHCPSameSubnetFixedIps(object):
def __init__(self): = '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):[, exp_host_data),, 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 = (
exp_opt_name = '/dhcp/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/opts'
exp_opt_data = ('tag:subnet-ffffffff-ffff-ffff-ffff-ffffffffffff,'
dm = self._get_dnsmasq(FakeV6NetworkStatefulDHCPSameSubnetFixedIps())
dm._output_opts_file()[, exp_host_data),, 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'

+ 25
- 0
releasenotes/notes/dhcp-dnsmasq-dhcp-host-addr6-list-support-45d104b3f7ce220e.yaml View File

@ -0,0 +1,25 @@
- |
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``.