Refactor and modularize edgeconfig scripts

- Entirely new netbox helper functions, using pynetbox and objects,
  instead of previous spaghetti code
- Allow for VM interfaces
- Allow device names to specify more than one segment of the DNS subdomain
- Split out forward and reverse DNS
- Fix issues with DHCP zone creation
- Support advertising NTP server via DHCP option

Playbooks
- Add QA, router, DNS, and user creation/config playbook
- Fix YAML formatting issues with playbooks

Change-Id: Id6c010ef1e122f4fd1bd97e9bb2128c4271947d0
diff --git a/scripts/nbhelper.py b/scripts/nbhelper.py
new file mode 100644
index 0000000..75195e9
--- /dev/null
+++ b/scripts/nbhelper.py
@@ -0,0 +1,972 @@
+#!/usr/bin/env python3
+
+# SPDX-FileCopyrightText: © 2021 Open Networking Foundation <support@opennetworking.org>
+# SPDX-License-Identifier: Apache-2.0
+
+# nbhelper.py
+# Helper functions for building YAML output from Netbox API calls
+
+from __future__ import absolute_import
+
+import re
+import sys
+import argparse
+import logging
+import netaddr
+import pynetbox
+import requests
+
+from ruamel import yaml
+
+# create shared logger
+logging.basicConfig()
+logger = logging.getLogger("nbh")
+
+# to dump YAML properly, using internal representers
+# see also:
+#  https://stackoverflow.com/questions/54378220/declare-data-type-to-ruamel-yaml-so-that-it-can-represen-serialize-it
+#  https://sourceforge.net/p/ruamel-yaml/code/ci/default/tree/representer.py
+
+ydump = yaml.YAML(typ="safe")
+ydump.representer.add_representer(
+    pynetbox.models.dcim.Devices, yaml.SafeRepresenter.represent_dict
+)
+ydump.representer.add_representer(
+    pynetbox.models.dcim.Interfaces, yaml.SafeRepresenter.represent_dict
+)
+ydump.representer.add_representer(
+    pynetbox.models.ipam.Prefixes, yaml.SafeRepresenter.represent_dict
+)
+ydump.representer.add_representer(
+    pynetbox.core.response.Record, yaml.SafeRepresenter.represent_dict
+)
+ydump.representer.add_representer(
+    pynetbox.models.ipam.IpAddresses, yaml.SafeRepresenter.represent_dict
+)
+ydump.representer.add_representer(
+    pynetbox.core.api.Api, yaml.SafeRepresenter.represent_none
+)
+
+
+def parse_cli_args(extra_args={}):
+    """
+    parse CLI arguments.  Can add extra arguments with a option:kwargs dict
+    """
+
+    parser = argparse.ArgumentParser(description="Netbox")
+
+    # Positional args
+    parser.add_argument(
+        "settings",
+        type=argparse.FileType("r"),
+        help="YAML Ansible inventory file w/NetBox API token",
+    )
+
+    parser.add_argument(
+        "--debug", action="store_true", help="Print additional debugging information"
+    )
+
+    if extra_args:
+        for ename, ekwargs in extra_args.items():
+            parser.add_argument(ename, **ekwargs)
+
+    args = parser.parse_args()
+
+    # only print log messages if debugging
+    if args.debug:
+        logger.setLevel(logging.DEBUG)
+    else:
+        logger.setLevel(logging.INFO)
+
+    return args
+
+
+class AttrDict(dict):
+    def __init__(self, *args, **kwargs):
+        super(AttrDict, self).__init__(*args, **kwargs)
+        self.__dict__ = self
+
+
+class NBHelper:
+    def __init__(self, args):
+
+        self.settings = yaml.safe_load(args.settings.read())
+
+        self.nbapi = pynetbox.api(
+            self.settings["api_endpoint"], token=self.settings["token"], threading=True,
+        )
+
+        if not self.settings["validate_certs"]:
+
+            session = requests.Session()
+            session.verify = False
+            self.nbapi.http_session = session
+
+        self.nb_version = self.nbapi.version
+
+    def all_prefixes(self):
+        """
+        Return a list of prefix objects
+        """
+
+        p_items = []
+
+        segments = 1
+
+        if "prefix_segments" in self.settings:
+            segments = self.settings["prefix_segments"]
+
+        for prefix in self.settings["ip_prefixes"]:
+            p_items.append(NBPrefix.get_prefix(self.nbapi, prefix, segments))
+
+        return p_items
+
+    @classmethod
+    def check_name_dns(cls, name):
+
+        badchars = re.search("[^a-z0-9.-]", name.lower(), re.ASCII)
+
+        if badchars:
+            logger.error(
+                "DNS name '%s' has one or more invalid characters: '%s'",
+                name,
+                badchars.group(0),
+            )
+            sys.exit(1)
+
+        return name.lower()
+
+    @classmethod
+    def clean_name_dns(cls, name):
+        return re.sub("[^a-z0-9.-]", "-", name.lower(), 0, re.ASCII)
+
+
+@yaml.yaml_object(ydump)
+class NBPrefix:
+
+    prefixes = {}
+
+    def __init__(self, api, prefix, name_segments):
+
+        self.nbapi = api
+        self.prefix = prefix
+        self.name_segments = name_segments
+
+        # get prefix information
+        self.prefix_data = self.nbapi.ipam.prefixes.get(prefix=self.prefix)
+        self.domain_extension = NBHelper.check_name_dns(self.prefix_data.description)
+
+        logger.debug(
+            "prefix %s, domain_extension %s, data: %s",
+            self.prefix,
+            self.domain_extension,
+            dict(self.prefix_data),
+        )
+
+        # ip centric info
+        self.dhcp_range = None
+        self.reserved_ips = {}
+        self.aos = {}
+
+        # build item lists
+        self.build_prefix()
+
+    @classmethod
+    def all_prefixes(cls):
+        return cls.prefixes
+
+    @classmethod
+    def get_prefix(cls, api, prefix, name_segments=1):
+        if prefix in cls.prefixes:
+            return cls.prefixes[prefix]
+
+        return NBPrefix(api, prefix, name_segments)
+
+    def __repr__(self):
+        return str(self.prefix)
+
+    @classmethod
+    def to_yaml(cls, representer, node):
+        return representer.represent_dict(
+            {
+                "dhcp_range": node.dhcp_range,
+                "reserved_ips": node.reserved_ips,
+                "aos": node.aos,
+                "prefix_data": dict(node.prefix_data),
+            }
+        )
+
+    def parent(self):
+        """
+        Get the parent prefix to this prefix
+
+        FIXME: Doesn't handle multiple layers of prefixes, returns first found
+        """
+
+        # get all parents of this prefix (include self)
+        possible_parents = self.nbapi.ipam.prefixes.filter(contains=self.prefix)
+
+        logger.debug("Prefix %s: possible parents %s", self.prefix, possible_parents)
+
+        # filter out self, return first found
+        for pparent in possible_parents:
+            if pparent.prefix != self.prefix:
+                return NBPrefix.get_prefix(
+                    self.nbapi, pparent.prefix, self.name_segments
+                )
+
+        return None
+
+    def build_prefix(self):
+        """
+        find ip information for items (devices/vms, reserved_ips, dhcp_range) in prefix
+        """
+
+        ips = self.nbapi.ipam.ip_addresses.filter(parent=self.prefix)
+
+        for ip in sorted(ips, key=lambda k: k["address"]):
+
+            logger.debug("prefix_item ip: %s, data: %s", ip, dict(ip))
+
+            # if it's a DHCP range, add that range to the dev list as prefix_dhcp
+            if ip.status.value == "dhcp":
+                self.dhcp_range = str(ip.address)
+                continue
+
+            # reserved IPs
+            if ip.status.value == "reserved":
+
+                res = {}
+                res["name"] = ip.description.lower().split(" ")[0]
+                res["description"] = ip.description
+                res["ip4"] = str(netaddr.IPNetwork(ip.address))
+                res["custom_fields"] = ip.custom_fields
+
+                self.reserved_ips[str(ip)] = res
+                continue
+
+            # devices and VMs
+            if ip.assigned_object:  # can be null if not assigned to a device/vm
+                aotype = ip.assigned_object_type
+
+                if aotype == "dcim.interface":
+
+                    self.aos[str(ip)] = NBDevice.get_dev(
+                        self.nbapi, ip.assigned_object.device.id,
+                    )
+
+                elif aotype == "virtualization.vminterface":
+                    self.aos[str(ip)] = NBVirtualMachine.get_vm(
+                        self.nbapi, ip.assigned_object.virtual_machine.id,
+                    )
+
+                else:
+                    logger.error("IP %s has unknown device type: %s", ip, aotype)
+                    sys.exit(1)
+
+            else:
+                logger.warning("Unknown IP type %s, with attributes: %s", ip, dict(ip))
+
+
+@yaml.yaml_object(ydump)
+class NBAssignedObject:
+    """
+    Assigned Object is either a Device or Virtual Machine, which function
+    nearly identically in the NetBox data model.
+
+    This parent class holds common functions for those two child classes
+    """
+
+    def __init__(self, api):
+        self.nbapi = api
+
+    def dns_name(self, ip, prefix):
+        """
+        Returns the DNS name for the device at this IP in the prefix
+        """
+
+        def first_segment_suffix(split_name, suffixes, segments):
+            first_seg = "-".join([split_name[0], *suffixes])
+
+            if segments > 1:
+                name = ".".join([first_seg, *split_name[1:segments]])
+            else:
+                name = first_seg
+
+            return name
+
+        # clean/split the device name
+        name_split = NBHelper.clean_name_dns(self.data.name).split(".")
+
+        # always add interface suffix to mgmt interfaces
+        if self.interfaces_by_ip[ip].mgmt_only:
+            return first_segment_suffix(
+                name_split, [self.interfaces_by_ip[ip].name], prefix.name_segments
+            )
+
+        # find all IP's for this device in the prefix that aren't mgmt interfaces
+        prefix_ips = []
+        for s_ip in self.ips:
+            if s_ip in prefix.aos and not self.interfaces_by_ip[s_ip].mgmt_only:
+                prefix_ips.append(s_ip)
+
+        # name to use when only one IP address for device in a prefix
+        simple_name = ".".join(name_split[0 : prefix.name_segments])
+
+        # if more than one non-mgmt IP in prefix
+        if len(prefix_ips) > 1:
+
+            # use bare name if primary IP address
+            try:  # skip if no primary_ip.address
+                if ip == self.data.primary_ip.address:
+                    return simple_name
+            except AttributeError:
+                pass
+
+            # else, suffix with the interface name, and the last octet of IP address
+            return first_segment_suffix(
+                name_split,
+                [
+                    self.interfaces_by_ip[ip].name,
+                    str(netaddr.IPNetwork(ip).ip.words[3]),
+                ],
+                prefix.name_segments,
+            )
+
+        # simplest case - only one IP in prefix, return simple_name
+        return simple_name
+
+    def dns_cnames(self, ip):
+        """
+        returns a list of cnames for this object, based on IP matches
+        """
+
+        cnames = []
+
+        for service in self.services:
+
+            # if not assigned to any IP's, service is on all IPs
+            if not service.ipaddresses:
+                cnames.append(service.name)
+                continue
+
+            # If assigned to an IP, only create a CNAME on that IP
+            for service_ip in service.ipaddresses:
+                if ip == service_ip.address:
+                    cnames.append(service.name)
+
+        return cnames
+
+    def has_service(self, cidr_ip, port, protocol):
+        """
+        Return True if this AO has a service using specific port and protocol combination
+        """
+
+        if (
+            cidr_ip in self.interfaces_by_ip
+            and not self.interfaces_by_ip[cidr_ip].mgmt_only
+        ):
+            for service in self.services:
+                if service.port == port and service.protocol.value == protocol:
+                    return True
+
+        return False
+
+    def primary_iface(self):
+        """
+        Returns the interface data for the device that has the primary_ip
+        """
+
+        if self.data["primary_ip"]:
+            return self.interfaces_by_ip[self.data["primary_ip"]["address"]]
+
+        return None
+
+
+@yaml.yaml_object(ydump)
+class NBDevice(NBAssignedObject):
+    """
+    Wraps a single Netbox device
+    Also caches all known devices in a class variable (devs)
+    """
+
+    devs = {}
+
+    def __init__(self, api, dev_id):
+
+        super().__init__(api)
+
+        self.id = dev_id
+        self.data = self.nbapi.dcim.devices.get(dev_id)
+        self.services = self.nbapi.ipam.services.filter(device_id=dev_id)
+
+        # not filled in unless specifically asked for (expensive for a 48 port switch)
+        self.interfaces = []
+        self.mgmt_interfaces = []
+
+        # look up all IP's for this device
+        self.ips = {
+            str(ip): ip for ip in self.nbapi.ipam.ip_addresses.filter(device_id=dev_id)
+        }
+
+        # look up interfaces by IP
+        self.interfaces_by_ip = {}
+        for ip, ip_data in self.ips.items():
+            if ip_data.assigned_object:
+                self.interfaces_by_ip[ip] = self.nbapi.dcim.interfaces.get(
+                    ip_data.assigned_object_id
+                )
+
+        logger.debug(
+            "NBDevice id: %d, data: %s, ips: %s", self.id, dict(self.data), self.ips,
+        )
+
+        self.devs[dev_id] = self
+
+    def __repr__(self):
+        return str(dict(self.data))
+
+    def get_interfaces(self):
+        if not self.interfaces:
+            self.interfaces = self.nbapi.dcim.interfaces.filter(device_id=self.id)
+
+        return self.interfaces
+
+    @classmethod
+    def get_dev(cls, api, dev_id):
+        if dev_id in cls.devs:
+            return cls.devs[dev_id]
+
+        return NBDevice(api, dev_id)
+
+    @classmethod
+    def all_devs(cls):
+        return cls.devs
+
+    @classmethod
+    def to_yaml(cls, representer, node):
+        return representer.represent_dict(
+            {
+                "data": node.data,
+                "services": node.services,
+                "ips": node.ips,
+                "interfaces_by_ip": node.interfaces_by_ip,
+            }
+        )
+
+
+@yaml.yaml_object(ydump)
+class NBVirtualMachine(NBAssignedObject):
+    """
+    VM equivalent of NBDevice
+    """
+
+    vms = {}
+
+    def __init__(self, api, vm_id):
+
+        super().__init__(api)
+
+        self.id = vm_id
+        self.data = self.nbapi.virtualization.virtual_machines.get(vm_id)
+        self.services = self.nbapi.ipam.services.filter(virtual_machine_id=vm_id)
+
+        # not filled in unless specifically asked for
+        self.interfaces = []
+
+        # look up all IP's for this device
+        self.ips = {
+            str(ip): ip
+            for ip in self.nbapi.ipam.ip_addresses.filter(virtual_machine_id=vm_id)
+        }
+
+        # look up interfaces by IP
+        self.interfaces_by_ip = {}
+        for ip, ip_data in self.ips.items():
+            if ip_data.assigned_object:
+                self.interfaces_by_ip[ip] = self.nbapi.virtualization.interfaces.get(
+                    ip_data.assigned_object_id
+                )
+                # hack as VM interfaces lack this key, and needed for services
+                self.interfaces_by_ip[ip].mgmt_only = False
+
+        logger.debug(
+            "NBVirtualMachine id: %d, data: %s, ips: %s",
+            self.id,
+            dict(self.data),
+            self.ips,
+        )
+
+        self.vms[vm_id] = self
+
+    def __repr__(self):
+        return str(dict(self.data))
+
+    def get_interfaces(self):
+        if not self.interfaces:
+            self.interfaces = self.nbapi.virtualization.interfaces.filter(
+                virtual_machine_id=self.id
+            )
+
+        return self.interfaces
+
+    @classmethod
+    def get_vm(cls, api, vm_id):
+        if vm_id in cls.vms:
+            return cls.vms[vm_id]
+
+        return NBVirtualMachine(api, vm_id)
+
+    @classmethod
+    def all_vms(cls):
+        return cls.vms
+
+    @classmethod
+    def to_yaml(cls, representer, node):
+        return representer.represent_dict(
+            {
+                "data": node.data,
+                "services": node.services,
+                "ips": node.ips,
+                "interfaces_by_ip": node.interfaces_by_ip,
+            }
+        )
+
+
+@yaml.yaml_object(ydump)
+class NBDNSForwardZone:
+
+    fwd_zones = {}
+
+    def __init__(self, prefix):
+
+        self.domain_extension = prefix.domain_extension
+
+        self.a_recs = {}
+        self.cname_recs = {}
+        self.srv_recs = {}
+        self.ns_recs = []
+        self.txt_recs = {}
+
+        if prefix.dhcp_range:
+            self.create_dhcp_fwd(prefix.dhcp_range)
+
+        for ip, ao in prefix.aos.items():
+            self.add_ao_records(prefix, ip, ao)
+
+        for ip, res in prefix.reserved_ips.items():
+            self.add_reserved(ip, res)
+
+        # reqquired for the add_fwd_cname function below
+        if callable(getattr(prefix, "parent")):
+            parent_prefix = prefix.parent()
+
+            if parent_prefix:
+                self.merge_parent_prefix(parent_prefix, prefix)
+
+        self.fwd_zones[self.domain_extension] = self
+
+    def __repr__(self):
+        return str(
+            {
+                "a": self.a_recs,
+                "cname": self.cname_recs,
+                "ns": self.ns_recs,
+                "srv": self.srv_recs,
+                "txt": self.txt_recs,
+            }
+        )
+
+    @classmethod
+    def add_fwd_cname(cls, cname, fqdn_dest):
+        """
+        Add an arbitrary CNAME (and possibly create the fwd zone if needed) pointing
+        at a FQDN destination name. It's used to support the per-IP "DNS name" field in NetBox
+        Note that the NS record
+        """
+
+        try:
+            fqdn_split = re.compile(r"([a-z]+)\.([a-z.]+)\.")
+            (short_name, extension) = fqdn_split.match(cname).groups()
+
+        except AttributeError:
+            logger.warning(
+                "Invalid DNS CNAME: '%s', must be in FQDN format: 'host.example.com.', ignored",
+                cname,
+            )
+            return
+
+        fake_prefix = AttrDict(
+            {
+                "domain_extension": extension,
+                "dhcp_range": None,
+                "aos": {},
+                "reserved_ips": {},
+                "parent": None,
+            }
+        )
+
+        fwd_zone = cls.get_fwd_zone(fake_prefix)
+
+        fwd_zone.cname_recs[short_name] = fqdn_dest
+
+    @classmethod
+    def get_fwd_zone(cls, prefix):
+        if prefix.domain_extension in cls.fwd_zones:
+            return cls.fwd_zones[prefix.domain_extension]
+
+        return NBDNSForwardZone(prefix)
+
+    @classmethod
+    def all_fwd_zones(cls):
+        return cls.fwd_zones
+
+    @classmethod
+    def to_yaml(cls, representer, node):
+        return representer.represent_dict(
+            {
+                "a": node.a_recs,
+                "cname": node.cname_recs,
+                "ns": node.ns_recs,
+                "srv": node.srv_recs,
+                "txt": node.txt_recs,
+            }
+        )
+
+    def fqdn(self, name):
+        return "%s.%s." % (name, self.domain_extension)
+
+    def create_dhcp_fwd(self, dhcp_range):
+
+        for ip in netaddr.IPNetwork(dhcp_range).iter_hosts():
+            self.a_recs["dhcp%03d" % (ip.words[3])] = str(ip)
+
+    def name_is_duplicate(self, name, target, record_type):
+        """
+        Returns True if name already exists in the zone as an A or CNAME
+        record, False otherwise
+        """
+
+        if name in self.a_recs:
+            logger.warning(
+                "Duplicate DNS record for name %s - A record to '%s', %s record to '%s'",
+                name,
+                self.a_recs[name],
+                record_type,
+                target,
+            )
+            return True
+
+        if name in self.cname_recs:
+            logger.warning(
+                "Duplicate DNS record for name %s - CNAME record to '%s', %s record to '%s'",
+                name,
+                self.cname_recs[name],
+                record_type,
+                target,
+            )
+            return True
+
+        return False
+
+    def add_ao_records(self, prefix, ip, ao):
+
+        name = ao.dns_name(ip, prefix)
+        target_ip = str(netaddr.IPNetwork(ip).ip)  # make bare IP, not CIDR format
+
+        # add A records
+        if not self.name_is_duplicate(name, target_ip, "A"):
+            self.a_recs[name] = target_ip
+
+        # add CNAME records that alias to this name
+        for cname in ao.dns_cnames(ip):
+            # check that it isn't a dupe
+            if not self.name_is_duplicate(cname, target_ip, "CNAME"):
+                self.cname_recs[cname] = self.fqdn(name)
+
+        # add NS records if this is a DNS server
+        if ao.has_service(ip, 53, "udp"):
+            self.ns_recs.append(self.fqdn(name))
+
+        # if a DNS name is set, add it as a CNAME
+        if ao.ips[ip]["dns_name"]:  # and ip == aos.data.primary_ip.address:
+            self.add_fwd_cname(ao.ips[ip]["dns_name"], self.fqdn(name))
+
+    def add_reserved(self, ip, res):
+
+        target_ip = str(netaddr.IPNetwork(ip).ip)  # make bare IP, not CIDR format
+
+        if not self.name_is_duplicate(res["name"], target_ip, "A"):
+            self.a_recs[res["name"]] = target_ip
+
+    def merge_parent_prefix(self, pprefix, prefix):
+
+        # only if no NS records exist already
+        if not self.ns_recs:
+            # scan parent prefix for services
+            for ip, ao in pprefix.aos.items():
+
+                # Create a DNS within this prefix pointing to out-of-prefix IP
+                # where DNS server is
+                name = ao.dns_name(ip, prefix)
+                target_ip = str(
+                    netaddr.IPNetwork(ip).ip
+                )  # make bare IP, not CIDR format
+
+                # add NS records if this is a DNS server
+                if ao.has_service(ip, 53, "udp"):
+                    self.a_recs[name] = target_ip
+                    self.ns_recs.append(self.fqdn(name))
+
+
+@yaml.yaml_object(ydump)
+class NBDNSReverseZones:
+    def __init__(self):
+
+        self.reverse_zones = {}
+
+    @classmethod
+    def to_yaml(cls, representer, node):
+        return representer.represent_dict(node.reverse_zones)
+
+    @classmethod
+    def canonicalize_rfc1918_prefix(cls, prefix):
+        """
+        RFC1918 prefixes need to be expanded to their widest canonical range to
+        group all reverse lookup domains together for reverse DNS with NSD/Unbound.
+        """
+
+        pnet = netaddr.IPNetwork(str(prefix))
+        (o1, o2, o3, o4) = pnet.network.words  # Split ipv4 octets
+        cidr_plen = pnet.prefixlen
+
+        if o1 == 10:
+            o2 = o3 = o4 = 0
+            cidr_plen = 8
+        elif (o1 == 172 and o2 >= 16 and o2 <= 31) or (o1 == 192 and o2 == 168):
+            o3 = o4 = 0
+            cidr_plen = 16
+
+        return "%s/%d" % (".".join(map(str, [o1, o2, o3, o4])), cidr_plen)
+
+    def add_prefix(self, prefix):
+
+        canonical_prefix = self.canonicalize_rfc1918_prefix(prefix)
+
+        if canonical_prefix in self.reverse_zones:
+            rzone = self.reverse_zones[canonical_prefix]
+        else:
+            rzone = {
+                "ns": [],
+                "ptr": {},
+            }
+
+        if prefix.dhcp_range:
+            # FIXME: doesn't check for duplicate entries
+            rzone["ptr"].update(self.create_dhcp_rev(prefix))
+
+        for ip, ao in prefix.aos.items():
+            target_ip = str(netaddr.IPNetwork(ip).ip)  # make bare IP, not CIDR format
+            ao_name = self.get_ao_name(ip, ao, prefix,)
+            rzone["ptr"][target_ip] = ao_name
+
+            # add NS records if this is a DNS server
+            if ao.has_service(ip, 53, "udp"):
+                rzone["ns"].append(ao_name)
+
+        parent_prefix = prefix.parent()
+
+        if parent_prefix:
+            self.merge_parent_prefix(rzone, parent_prefix)
+
+        self.reverse_zones[canonical_prefix] = rzone
+
+    def merge_parent_prefix(self, rzone, pprefix):
+
+        # parent items
+        p_ns = []
+
+        # scan parent prefix for services
+        for ip, ao in pprefix.aos.items():
+
+            ao_name = self.get_ao_name(ip, ao, pprefix,)
+
+            # add NS records if this is a DNS server
+            if ao.has_service(ip, 53, "udp"):
+                p_ns.append(ao_name)
+
+        # set DNS servers if none in rzone
+        if not rzone["ns"]:
+            rzone["ns"] = p_ns
+
+    def create_dhcp_rev(self, prefix):
+
+        dhcp_rzone = {}
+
+        for ip in netaddr.IPNetwork(prefix.dhcp_range).iter_hosts():
+            dhcp_rzone[str(ip)] = "dhcp%03d.%s." % (
+                ip.words[3],
+                prefix.domain_extension,
+            )
+
+        return dhcp_rzone
+
+    def get_ao_name(self, ip, ao, prefix):
+        short_name = ao.dns_name(ip, prefix)
+        return "%s.%s." % (short_name, prefix.domain_extension)
+
+
+@yaml.yaml_object(ydump)
+class NBDHCPSubnet:
+    def __init__(self, prefix):
+
+        self.domain_extension = prefix.domain_extension
+
+        self.subnet = None
+        self.range = None
+        self.first_ip = None
+        self.hosts = []
+        self.routers = []
+        self.dns_servers = []
+        self.dns_search = []
+        self.tftpd_server = None
+        self.ntp_servers = []
+        self.dhcpd_interface = None
+
+        self.add_prefix(prefix)
+
+        for ip, ao in prefix.aos.items():
+            self.add_ao(str(ip), ao, prefix)
+
+        parent_prefix = prefix.parent()
+
+        if parent_prefix:
+            self.merge_parent_prefix(parent_prefix)
+
+    def add_prefix(self, prefix):
+
+        self.subnet = str(prefix)
+
+        self.first_ip = str(netaddr.IPAddress(netaddr.IPNetwork(str(prefix)).first + 1))
+
+        self.dns_search = [prefix.domain_extension]
+
+        if prefix.dhcp_range:
+            self.range = prefix.dhcp_range
+
+        for ip, res in prefix.reserved_ips.items():
+            # routers are reserved IP's that start with 'router" in the IP description
+            if re.match("router", res["description"]):
+                router = {"ip": str(netaddr.IPNetwork(ip).ip)}
+
+                if (
+                    "rfc3442routes" in res["custom_fields"]
+                    and res["custom_fields"]["rfc3442routes"]
+                ):
+                    # split on whitespace
+                    router["rfc3442routes"] = re.split(
+                        r"\s+", res["custom_fields"]["rfc3442routes"]
+                    )
+
+                self.routers.append(router)
+
+        # set first IP to router if not set otherwise.
+        if not self.routers:
+            router = {"ip": self.first_ip}
+
+            self.routers.append(router)
+
+    def add_ao(self, ip, ao, prefix):
+
+        target_ip = str(netaddr.IPNetwork(ip).ip)  # make bare IP, not CIDR format
+
+        # find the DHCP interface if it's this IP
+        if target_ip == self.first_ip:
+            self.dhcpd_interface = ao.interfaces_by_ip[ip].name
+
+        name = ao.dns_name(ip, prefix)
+
+        # add only devices that have a macaddr for this IP
+        if ip in ao.interfaces_by_ip:
+
+            mac_addr = dict(ao.interfaces_by_ip[ip]).get("mac_address")
+
+            if mac_addr and mac_addr.strip():  # if exists and not blank
+                self.hosts.append(
+                    {"name": name, "ip_addr": target_ip, "mac_addr": mac_addr.lower(),}
+                )
+
+        # add dns servers
+        if ao.has_service(ip, 53, "udp"):
+            self.dns_servers.append(target_ip)
+
+        # add tftp server
+        if ao.has_service(ip, 69, "udp"):
+            if not self.tftpd_server:
+                self.tftpd_server = target_ip
+            else:
+                logger.warning(
+                    "Duplicate TFTP servers in prefix, using first of %s and %s",
+                    self.tftpd_server,
+                    target_ip,
+                )
+
+        # add NTP servers
+        if ao.has_service(ip, 123, "udp"):
+            self.ntp_servers.append(target_ip)
+
+    def merge_parent_prefix(self, pprefix):
+
+        # parent items
+        p_dns_servers = []
+        p_tftpd_server = None
+        p_ntp_servers = []
+
+        # scan parent prefix for services
+        for ip, ao in pprefix.aos.items():
+
+            target_ip = str(netaddr.IPNetwork(ip).ip)
+
+            # add dns servers
+            if ao.has_service(ip, 53, "udp"):
+                p_dns_servers.append(target_ip)
+
+            # add tftp server
+            if ao.has_service(ip, 69, "udp"):
+                if not p_tftpd_server:
+                    p_tftpd_server = target_ip
+                else:
+                    logger.warning(
+                        "Duplicate TFTP servers in parent prefix, using first of %s and %s",
+                        p_tftpd_server,
+                        target_ip,
+                    )
+
+            # add NTP servers
+            if ao.has_service(ip, 123, "udp"):
+                p_ntp_servers.append(target_ip)
+
+        # merge if doesn't exist in prefix
+        if not self.dns_servers:
+            self.dns_servers = p_dns_servers
+
+        if not self.tftpd_server:
+            self.tftpd_server = p_tftpd_server
+
+        if not self.ntp_servers:
+            self.ntp_servers = p_ntp_servers
+
+    @classmethod
+    def to_yaml(cls, representer, node):
+        return representer.represent_dict(
+            {
+                "subnet": node.subnet,
+                "range": node.range,
+                "routers": node.routers,
+                "hosts": node.hosts,
+                "dns_servers": node.dns_servers,
+                "dns_search": node.dns_search,
+                "tftpd_server": node.tftpd_server,
+                "ntp_servers": node.ntp_servers,
+            }
+        )