blob: 75195e98149e8edb26ea9059dcde475ea923b341 [file] [log] [blame]
Zack Williams2aeb3ef2021-06-11 17:10:36 -07001#!/usr/bin/env python3
2
3# SPDX-FileCopyrightText: © 2021 Open Networking Foundation <support@opennetworking.org>
4# SPDX-License-Identifier: Apache-2.0
5
6# nbhelper.py
7# Helper functions for building YAML output from Netbox API calls
8
9from __future__ import absolute_import
10
11import re
12import sys
13import argparse
14import logging
15import netaddr
16import pynetbox
17import requests
18
19from ruamel import yaml
20
21# create shared logger
22logging.basicConfig()
23logger = logging.getLogger("nbh")
24
25# to dump YAML properly, using internal representers
26# see also:
27# https://stackoverflow.com/questions/54378220/declare-data-type-to-ruamel-yaml-so-that-it-can-represen-serialize-it
28# https://sourceforge.net/p/ruamel-yaml/code/ci/default/tree/representer.py
29
30ydump = yaml.YAML(typ="safe")
31ydump.representer.add_representer(
32 pynetbox.models.dcim.Devices, yaml.SafeRepresenter.represent_dict
33)
34ydump.representer.add_representer(
35 pynetbox.models.dcim.Interfaces, yaml.SafeRepresenter.represent_dict
36)
37ydump.representer.add_representer(
38 pynetbox.models.ipam.Prefixes, yaml.SafeRepresenter.represent_dict
39)
40ydump.representer.add_representer(
41 pynetbox.core.response.Record, yaml.SafeRepresenter.represent_dict
42)
43ydump.representer.add_representer(
44 pynetbox.models.ipam.IpAddresses, yaml.SafeRepresenter.represent_dict
45)
46ydump.representer.add_representer(
47 pynetbox.core.api.Api, yaml.SafeRepresenter.represent_none
48)
49
50
51def parse_cli_args(extra_args={}):
52 """
53 parse CLI arguments. Can add extra arguments with a option:kwargs dict
54 """
55
56 parser = argparse.ArgumentParser(description="Netbox")
57
58 # Positional args
59 parser.add_argument(
60 "settings",
61 type=argparse.FileType("r"),
62 help="YAML Ansible inventory file w/NetBox API token",
63 )
64
65 parser.add_argument(
66 "--debug", action="store_true", help="Print additional debugging information"
67 )
68
69 if extra_args:
70 for ename, ekwargs in extra_args.items():
71 parser.add_argument(ename, **ekwargs)
72
73 args = parser.parse_args()
74
75 # only print log messages if debugging
76 if args.debug:
77 logger.setLevel(logging.DEBUG)
78 else:
79 logger.setLevel(logging.INFO)
80
81 return args
82
83
84class AttrDict(dict):
85 def __init__(self, *args, **kwargs):
86 super(AttrDict, self).__init__(*args, **kwargs)
87 self.__dict__ = self
88
89
90class NBHelper:
91 def __init__(self, args):
92
93 self.settings = yaml.safe_load(args.settings.read())
94
95 self.nbapi = pynetbox.api(
96 self.settings["api_endpoint"], token=self.settings["token"], threading=True,
97 )
98
99 if not self.settings["validate_certs"]:
100
101 session = requests.Session()
102 session.verify = False
103 self.nbapi.http_session = session
104
105 self.nb_version = self.nbapi.version
106
107 def all_prefixes(self):
108 """
109 Return a list of prefix objects
110 """
111
112 p_items = []
113
114 segments = 1
115
116 if "prefix_segments" in self.settings:
117 segments = self.settings["prefix_segments"]
118
119 for prefix in self.settings["ip_prefixes"]:
120 p_items.append(NBPrefix.get_prefix(self.nbapi, prefix, segments))
121
122 return p_items
123
124 @classmethod
125 def check_name_dns(cls, name):
126
127 badchars = re.search("[^a-z0-9.-]", name.lower(), re.ASCII)
128
129 if badchars:
130 logger.error(
131 "DNS name '%s' has one or more invalid characters: '%s'",
132 name,
133 badchars.group(0),
134 )
135 sys.exit(1)
136
137 return name.lower()
138
139 @classmethod
140 def clean_name_dns(cls, name):
141 return re.sub("[^a-z0-9.-]", "-", name.lower(), 0, re.ASCII)
142
143
144@yaml.yaml_object(ydump)
145class NBPrefix:
146
147 prefixes = {}
148
149 def __init__(self, api, prefix, name_segments):
150
151 self.nbapi = api
152 self.prefix = prefix
153 self.name_segments = name_segments
154
155 # get prefix information
156 self.prefix_data = self.nbapi.ipam.prefixes.get(prefix=self.prefix)
157 self.domain_extension = NBHelper.check_name_dns(self.prefix_data.description)
158
159 logger.debug(
160 "prefix %s, domain_extension %s, data: %s",
161 self.prefix,
162 self.domain_extension,
163 dict(self.prefix_data),
164 )
165
166 # ip centric info
167 self.dhcp_range = None
168 self.reserved_ips = {}
169 self.aos = {}
170
171 # build item lists
172 self.build_prefix()
173
174 @classmethod
175 def all_prefixes(cls):
176 return cls.prefixes
177
178 @classmethod
179 def get_prefix(cls, api, prefix, name_segments=1):
180 if prefix in cls.prefixes:
181 return cls.prefixes[prefix]
182
183 return NBPrefix(api, prefix, name_segments)
184
185 def __repr__(self):
186 return str(self.prefix)
187
188 @classmethod
189 def to_yaml(cls, representer, node):
190 return representer.represent_dict(
191 {
192 "dhcp_range": node.dhcp_range,
193 "reserved_ips": node.reserved_ips,
194 "aos": node.aos,
195 "prefix_data": dict(node.prefix_data),
196 }
197 )
198
199 def parent(self):
200 """
201 Get the parent prefix to this prefix
202
203 FIXME: Doesn't handle multiple layers of prefixes, returns first found
204 """
205
206 # get all parents of this prefix (include self)
207 possible_parents = self.nbapi.ipam.prefixes.filter(contains=self.prefix)
208
209 logger.debug("Prefix %s: possible parents %s", self.prefix, possible_parents)
210
211 # filter out self, return first found
212 for pparent in possible_parents:
213 if pparent.prefix != self.prefix:
214 return NBPrefix.get_prefix(
215 self.nbapi, pparent.prefix, self.name_segments
216 )
217
218 return None
219
220 def build_prefix(self):
221 """
222 find ip information for items (devices/vms, reserved_ips, dhcp_range) in prefix
223 """
224
225 ips = self.nbapi.ipam.ip_addresses.filter(parent=self.prefix)
226
227 for ip in sorted(ips, key=lambda k: k["address"]):
228
229 logger.debug("prefix_item ip: %s, data: %s", ip, dict(ip))
230
231 # if it's a DHCP range, add that range to the dev list as prefix_dhcp
232 if ip.status.value == "dhcp":
233 self.dhcp_range = str(ip.address)
234 continue
235
236 # reserved IPs
237 if ip.status.value == "reserved":
238
239 res = {}
240 res["name"] = ip.description.lower().split(" ")[0]
241 res["description"] = ip.description
242 res["ip4"] = str(netaddr.IPNetwork(ip.address))
243 res["custom_fields"] = ip.custom_fields
244
245 self.reserved_ips[str(ip)] = res
246 continue
247
248 # devices and VMs
249 if ip.assigned_object: # can be null if not assigned to a device/vm
250 aotype = ip.assigned_object_type
251
252 if aotype == "dcim.interface":
253
254 self.aos[str(ip)] = NBDevice.get_dev(
255 self.nbapi, ip.assigned_object.device.id,
256 )
257
258 elif aotype == "virtualization.vminterface":
259 self.aos[str(ip)] = NBVirtualMachine.get_vm(
260 self.nbapi, ip.assigned_object.virtual_machine.id,
261 )
262
263 else:
264 logger.error("IP %s has unknown device type: %s", ip, aotype)
265 sys.exit(1)
266
267 else:
268 logger.warning("Unknown IP type %s, with attributes: %s", ip, dict(ip))
269
270
271@yaml.yaml_object(ydump)
272class NBAssignedObject:
273 """
274 Assigned Object is either a Device or Virtual Machine, which function
275 nearly identically in the NetBox data model.
276
277 This parent class holds common functions for those two child classes
278 """
279
280 def __init__(self, api):
281 self.nbapi = api
282
283 def dns_name(self, ip, prefix):
284 """
285 Returns the DNS name for the device at this IP in the prefix
286 """
287
288 def first_segment_suffix(split_name, suffixes, segments):
289 first_seg = "-".join([split_name[0], *suffixes])
290
291 if segments > 1:
292 name = ".".join([first_seg, *split_name[1:segments]])
293 else:
294 name = first_seg
295
296 return name
297
298 # clean/split the device name
299 name_split = NBHelper.clean_name_dns(self.data.name).split(".")
300
301 # always add interface suffix to mgmt interfaces
302 if self.interfaces_by_ip[ip].mgmt_only:
303 return first_segment_suffix(
304 name_split, [self.interfaces_by_ip[ip].name], prefix.name_segments
305 )
306
307 # find all IP's for this device in the prefix that aren't mgmt interfaces
308 prefix_ips = []
309 for s_ip in self.ips:
310 if s_ip in prefix.aos and not self.interfaces_by_ip[s_ip].mgmt_only:
311 prefix_ips.append(s_ip)
312
313 # name to use when only one IP address for device in a prefix
314 simple_name = ".".join(name_split[0 : prefix.name_segments])
315
316 # if more than one non-mgmt IP in prefix
317 if len(prefix_ips) > 1:
318
319 # use bare name if primary IP address
320 try: # skip if no primary_ip.address
321 if ip == self.data.primary_ip.address:
322 return simple_name
323 except AttributeError:
324 pass
325
326 # else, suffix with the interface name, and the last octet of IP address
327 return first_segment_suffix(
328 name_split,
329 [
330 self.interfaces_by_ip[ip].name,
331 str(netaddr.IPNetwork(ip).ip.words[3]),
332 ],
333 prefix.name_segments,
334 )
335
336 # simplest case - only one IP in prefix, return simple_name
337 return simple_name
338
339 def dns_cnames(self, ip):
340 """
341 returns a list of cnames for this object, based on IP matches
342 """
343
344 cnames = []
345
346 for service in self.services:
347
348 # if not assigned to any IP's, service is on all IPs
349 if not service.ipaddresses:
350 cnames.append(service.name)
351 continue
352
353 # If assigned to an IP, only create a CNAME on that IP
354 for service_ip in service.ipaddresses:
355 if ip == service_ip.address:
356 cnames.append(service.name)
357
358 return cnames
359
360 def has_service(self, cidr_ip, port, protocol):
361 """
362 Return True if this AO has a service using specific port and protocol combination
363 """
364
365 if (
366 cidr_ip in self.interfaces_by_ip
367 and not self.interfaces_by_ip[cidr_ip].mgmt_only
368 ):
369 for service in self.services:
370 if service.port == port and service.protocol.value == protocol:
371 return True
372
373 return False
374
375 def primary_iface(self):
376 """
377 Returns the interface data for the device that has the primary_ip
378 """
379
380 if self.data["primary_ip"]:
381 return self.interfaces_by_ip[self.data["primary_ip"]["address"]]
382
383 return None
384
385
386@yaml.yaml_object(ydump)
387class NBDevice(NBAssignedObject):
388 """
389 Wraps a single Netbox device
390 Also caches all known devices in a class variable (devs)
391 """
392
393 devs = {}
394
395 def __init__(self, api, dev_id):
396
397 super().__init__(api)
398
399 self.id = dev_id
400 self.data = self.nbapi.dcim.devices.get(dev_id)
401 self.services = self.nbapi.ipam.services.filter(device_id=dev_id)
402
403 # not filled in unless specifically asked for (expensive for a 48 port switch)
404 self.interfaces = []
405 self.mgmt_interfaces = []
406
407 # look up all IP's for this device
408 self.ips = {
409 str(ip): ip for ip in self.nbapi.ipam.ip_addresses.filter(device_id=dev_id)
410 }
411
412 # look up interfaces by IP
413 self.interfaces_by_ip = {}
414 for ip, ip_data in self.ips.items():
415 if ip_data.assigned_object:
416 self.interfaces_by_ip[ip] = self.nbapi.dcim.interfaces.get(
417 ip_data.assigned_object_id
418 )
419
420 logger.debug(
421 "NBDevice id: %d, data: %s, ips: %s", self.id, dict(self.data), self.ips,
422 )
423
424 self.devs[dev_id] = self
425
426 def __repr__(self):
427 return str(dict(self.data))
428
429 def get_interfaces(self):
430 if not self.interfaces:
431 self.interfaces = self.nbapi.dcim.interfaces.filter(device_id=self.id)
432
433 return self.interfaces
434
435 @classmethod
436 def get_dev(cls, api, dev_id):
437 if dev_id in cls.devs:
438 return cls.devs[dev_id]
439
440 return NBDevice(api, dev_id)
441
442 @classmethod
443 def all_devs(cls):
444 return cls.devs
445
446 @classmethod
447 def to_yaml(cls, representer, node):
448 return representer.represent_dict(
449 {
450 "data": node.data,
451 "services": node.services,
452 "ips": node.ips,
453 "interfaces_by_ip": node.interfaces_by_ip,
454 }
455 )
456
457
458@yaml.yaml_object(ydump)
459class NBVirtualMachine(NBAssignedObject):
460 """
461 VM equivalent of NBDevice
462 """
463
464 vms = {}
465
466 def __init__(self, api, vm_id):
467
468 super().__init__(api)
469
470 self.id = vm_id
471 self.data = self.nbapi.virtualization.virtual_machines.get(vm_id)
472 self.services = self.nbapi.ipam.services.filter(virtual_machine_id=vm_id)
473
474 # not filled in unless specifically asked for
475 self.interfaces = []
476
477 # look up all IP's for this device
478 self.ips = {
479 str(ip): ip
480 for ip in self.nbapi.ipam.ip_addresses.filter(virtual_machine_id=vm_id)
481 }
482
483 # look up interfaces by IP
484 self.interfaces_by_ip = {}
485 for ip, ip_data in self.ips.items():
486 if ip_data.assigned_object:
487 self.interfaces_by_ip[ip] = self.nbapi.virtualization.interfaces.get(
488 ip_data.assigned_object_id
489 )
490 # hack as VM interfaces lack this key, and needed for services
491 self.interfaces_by_ip[ip].mgmt_only = False
492
493 logger.debug(
494 "NBVirtualMachine id: %d, data: %s, ips: %s",
495 self.id,
496 dict(self.data),
497 self.ips,
498 )
499
500 self.vms[vm_id] = self
501
502 def __repr__(self):
503 return str(dict(self.data))
504
505 def get_interfaces(self):
506 if not self.interfaces:
507 self.interfaces = self.nbapi.virtualization.interfaces.filter(
508 virtual_machine_id=self.id
509 )
510
511 return self.interfaces
512
513 @classmethod
514 def get_vm(cls, api, vm_id):
515 if vm_id in cls.vms:
516 return cls.vms[vm_id]
517
518 return NBVirtualMachine(api, vm_id)
519
520 @classmethod
521 def all_vms(cls):
522 return cls.vms
523
524 @classmethod
525 def to_yaml(cls, representer, node):
526 return representer.represent_dict(
527 {
528 "data": node.data,
529 "services": node.services,
530 "ips": node.ips,
531 "interfaces_by_ip": node.interfaces_by_ip,
532 }
533 )
534
535
536@yaml.yaml_object(ydump)
537class NBDNSForwardZone:
538
539 fwd_zones = {}
540
541 def __init__(self, prefix):
542
543 self.domain_extension = prefix.domain_extension
544
545 self.a_recs = {}
546 self.cname_recs = {}
547 self.srv_recs = {}
548 self.ns_recs = []
549 self.txt_recs = {}
550
551 if prefix.dhcp_range:
552 self.create_dhcp_fwd(prefix.dhcp_range)
553
554 for ip, ao in prefix.aos.items():
555 self.add_ao_records(prefix, ip, ao)
556
557 for ip, res in prefix.reserved_ips.items():
558 self.add_reserved(ip, res)
559
560 # reqquired for the add_fwd_cname function below
561 if callable(getattr(prefix, "parent")):
562 parent_prefix = prefix.parent()
563
564 if parent_prefix:
565 self.merge_parent_prefix(parent_prefix, prefix)
566
567 self.fwd_zones[self.domain_extension] = self
568
569 def __repr__(self):
570 return str(
571 {
572 "a": self.a_recs,
573 "cname": self.cname_recs,
574 "ns": self.ns_recs,
575 "srv": self.srv_recs,
576 "txt": self.txt_recs,
577 }
578 )
579
580 @classmethod
581 def add_fwd_cname(cls, cname, fqdn_dest):
582 """
583 Add an arbitrary CNAME (and possibly create the fwd zone if needed) pointing
584 at a FQDN destination name. It's used to support the per-IP "DNS name" field in NetBox
585 Note that the NS record
586 """
587
588 try:
589 fqdn_split = re.compile(r"([a-z]+)\.([a-z.]+)\.")
590 (short_name, extension) = fqdn_split.match(cname).groups()
591
592 except AttributeError:
593 logger.warning(
594 "Invalid DNS CNAME: '%s', must be in FQDN format: 'host.example.com.', ignored",
595 cname,
596 )
597 return
598
599 fake_prefix = AttrDict(
600 {
601 "domain_extension": extension,
602 "dhcp_range": None,
603 "aos": {},
604 "reserved_ips": {},
605 "parent": None,
606 }
607 )
608
609 fwd_zone = cls.get_fwd_zone(fake_prefix)
610
611 fwd_zone.cname_recs[short_name] = fqdn_dest
612
613 @classmethod
614 def get_fwd_zone(cls, prefix):
615 if prefix.domain_extension in cls.fwd_zones:
616 return cls.fwd_zones[prefix.domain_extension]
617
618 return NBDNSForwardZone(prefix)
619
620 @classmethod
621 def all_fwd_zones(cls):
622 return cls.fwd_zones
623
624 @classmethod
625 def to_yaml(cls, representer, node):
626 return representer.represent_dict(
627 {
628 "a": node.a_recs,
629 "cname": node.cname_recs,
630 "ns": node.ns_recs,
631 "srv": node.srv_recs,
632 "txt": node.txt_recs,
633 }
634 )
635
636 def fqdn(self, name):
637 return "%s.%s." % (name, self.domain_extension)
638
639 def create_dhcp_fwd(self, dhcp_range):
640
641 for ip in netaddr.IPNetwork(dhcp_range).iter_hosts():
642 self.a_recs["dhcp%03d" % (ip.words[3])] = str(ip)
643
644 def name_is_duplicate(self, name, target, record_type):
645 """
646 Returns True if name already exists in the zone as an A or CNAME
647 record, False otherwise
648 """
649
650 if name in self.a_recs:
651 logger.warning(
652 "Duplicate DNS record for name %s - A record to '%s', %s record to '%s'",
653 name,
654 self.a_recs[name],
655 record_type,
656 target,
657 )
658 return True
659
660 if name in self.cname_recs:
661 logger.warning(
662 "Duplicate DNS record for name %s - CNAME record to '%s', %s record to '%s'",
663 name,
664 self.cname_recs[name],
665 record_type,
666 target,
667 )
668 return True
669
670 return False
671
672 def add_ao_records(self, prefix, ip, ao):
673
674 name = ao.dns_name(ip, prefix)
675 target_ip = str(netaddr.IPNetwork(ip).ip) # make bare IP, not CIDR format
676
677 # add A records
678 if not self.name_is_duplicate(name, target_ip, "A"):
679 self.a_recs[name] = target_ip
680
681 # add CNAME records that alias to this name
682 for cname in ao.dns_cnames(ip):
683 # check that it isn't a dupe
684 if not self.name_is_duplicate(cname, target_ip, "CNAME"):
685 self.cname_recs[cname] = self.fqdn(name)
686
687 # add NS records if this is a DNS server
688 if ao.has_service(ip, 53, "udp"):
689 self.ns_recs.append(self.fqdn(name))
690
691 # if a DNS name is set, add it as a CNAME
692 if ao.ips[ip]["dns_name"]: # and ip == aos.data.primary_ip.address:
693 self.add_fwd_cname(ao.ips[ip]["dns_name"], self.fqdn(name))
694
695 def add_reserved(self, ip, res):
696
697 target_ip = str(netaddr.IPNetwork(ip).ip) # make bare IP, not CIDR format
698
699 if not self.name_is_duplicate(res["name"], target_ip, "A"):
700 self.a_recs[res["name"]] = target_ip
701
702 def merge_parent_prefix(self, pprefix, prefix):
703
704 # only if no NS records exist already
705 if not self.ns_recs:
706 # scan parent prefix for services
707 for ip, ao in pprefix.aos.items():
708
709 # Create a DNS within this prefix pointing to out-of-prefix IP
710 # where DNS server is
711 name = ao.dns_name(ip, prefix)
712 target_ip = str(
713 netaddr.IPNetwork(ip).ip
714 ) # make bare IP, not CIDR format
715
716 # add NS records if this is a DNS server
717 if ao.has_service(ip, 53, "udp"):
718 self.a_recs[name] = target_ip
719 self.ns_recs.append(self.fqdn(name))
720
721
722@yaml.yaml_object(ydump)
723class NBDNSReverseZones:
724 def __init__(self):
725
726 self.reverse_zones = {}
727
728 @classmethod
729 def to_yaml(cls, representer, node):
730 return representer.represent_dict(node.reverse_zones)
731
732 @classmethod
733 def canonicalize_rfc1918_prefix(cls, prefix):
734 """
735 RFC1918 prefixes need to be expanded to their widest canonical range to
736 group all reverse lookup domains together for reverse DNS with NSD/Unbound.
737 """
738
739 pnet = netaddr.IPNetwork(str(prefix))
740 (o1, o2, o3, o4) = pnet.network.words # Split ipv4 octets
741 cidr_plen = pnet.prefixlen
742
743 if o1 == 10:
744 o2 = o3 = o4 = 0
745 cidr_plen = 8
746 elif (o1 == 172 and o2 >= 16 and o2 <= 31) or (o1 == 192 and o2 == 168):
747 o3 = o4 = 0
748 cidr_plen = 16
749
750 return "%s/%d" % (".".join(map(str, [o1, o2, o3, o4])), cidr_plen)
751
752 def add_prefix(self, prefix):
753
754 canonical_prefix = self.canonicalize_rfc1918_prefix(prefix)
755
756 if canonical_prefix in self.reverse_zones:
757 rzone = self.reverse_zones[canonical_prefix]
758 else:
759 rzone = {
760 "ns": [],
761 "ptr": {},
762 }
763
764 if prefix.dhcp_range:
765 # FIXME: doesn't check for duplicate entries
766 rzone["ptr"].update(self.create_dhcp_rev(prefix))
767
768 for ip, ao in prefix.aos.items():
769 target_ip = str(netaddr.IPNetwork(ip).ip) # make bare IP, not CIDR format
770 ao_name = self.get_ao_name(ip, ao, prefix,)
771 rzone["ptr"][target_ip] = ao_name
772
773 # add NS records if this is a DNS server
774 if ao.has_service(ip, 53, "udp"):
775 rzone["ns"].append(ao_name)
776
777 parent_prefix = prefix.parent()
778
779 if parent_prefix:
780 self.merge_parent_prefix(rzone, parent_prefix)
781
782 self.reverse_zones[canonical_prefix] = rzone
783
784 def merge_parent_prefix(self, rzone, pprefix):
785
786 # parent items
787 p_ns = []
788
789 # scan parent prefix for services
790 for ip, ao in pprefix.aos.items():
791
792 ao_name = self.get_ao_name(ip, ao, pprefix,)
793
794 # add NS records if this is a DNS server
795 if ao.has_service(ip, 53, "udp"):
796 p_ns.append(ao_name)
797
798 # set DNS servers if none in rzone
799 if not rzone["ns"]:
800 rzone["ns"] = p_ns
801
802 def create_dhcp_rev(self, prefix):
803
804 dhcp_rzone = {}
805
806 for ip in netaddr.IPNetwork(prefix.dhcp_range).iter_hosts():
807 dhcp_rzone[str(ip)] = "dhcp%03d.%s." % (
808 ip.words[3],
809 prefix.domain_extension,
810 )
811
812 return dhcp_rzone
813
814 def get_ao_name(self, ip, ao, prefix):
815 short_name = ao.dns_name(ip, prefix)
816 return "%s.%s." % (short_name, prefix.domain_extension)
817
818
819@yaml.yaml_object(ydump)
820class NBDHCPSubnet:
821 def __init__(self, prefix):
822
823 self.domain_extension = prefix.domain_extension
824
825 self.subnet = None
826 self.range = None
827 self.first_ip = None
828 self.hosts = []
829 self.routers = []
830 self.dns_servers = []
831 self.dns_search = []
832 self.tftpd_server = None
833 self.ntp_servers = []
834 self.dhcpd_interface = None
835
836 self.add_prefix(prefix)
837
838 for ip, ao in prefix.aos.items():
839 self.add_ao(str(ip), ao, prefix)
840
841 parent_prefix = prefix.parent()
842
843 if parent_prefix:
844 self.merge_parent_prefix(parent_prefix)
845
846 def add_prefix(self, prefix):
847
848 self.subnet = str(prefix)
849
850 self.first_ip = str(netaddr.IPAddress(netaddr.IPNetwork(str(prefix)).first + 1))
851
852 self.dns_search = [prefix.domain_extension]
853
854 if prefix.dhcp_range:
855 self.range = prefix.dhcp_range
856
857 for ip, res in prefix.reserved_ips.items():
858 # routers are reserved IP's that start with 'router" in the IP description
859 if re.match("router", res["description"]):
860 router = {"ip": str(netaddr.IPNetwork(ip).ip)}
861
862 if (
863 "rfc3442routes" in res["custom_fields"]
864 and res["custom_fields"]["rfc3442routes"]
865 ):
866 # split on whitespace
867 router["rfc3442routes"] = re.split(
868 r"\s+", res["custom_fields"]["rfc3442routes"]
869 )
870
871 self.routers.append(router)
872
873 # set first IP to router if not set otherwise.
874 if not self.routers:
875 router = {"ip": self.first_ip}
876
877 self.routers.append(router)
878
879 def add_ao(self, ip, ao, prefix):
880
881 target_ip = str(netaddr.IPNetwork(ip).ip) # make bare IP, not CIDR format
882
883 # find the DHCP interface if it's this IP
884 if target_ip == self.first_ip:
885 self.dhcpd_interface = ao.interfaces_by_ip[ip].name
886
887 name = ao.dns_name(ip, prefix)
888
889 # add only devices that have a macaddr for this IP
890 if ip in ao.interfaces_by_ip:
891
892 mac_addr = dict(ao.interfaces_by_ip[ip]).get("mac_address")
893
894 if mac_addr and mac_addr.strip(): # if exists and not blank
895 self.hosts.append(
896 {"name": name, "ip_addr": target_ip, "mac_addr": mac_addr.lower(),}
897 )
898
899 # add dns servers
900 if ao.has_service(ip, 53, "udp"):
901 self.dns_servers.append(target_ip)
902
903 # add tftp server
904 if ao.has_service(ip, 69, "udp"):
905 if not self.tftpd_server:
906 self.tftpd_server = target_ip
907 else:
908 logger.warning(
909 "Duplicate TFTP servers in prefix, using first of %s and %s",
910 self.tftpd_server,
911 target_ip,
912 )
913
914 # add NTP servers
915 if ao.has_service(ip, 123, "udp"):
916 self.ntp_servers.append(target_ip)
917
918 def merge_parent_prefix(self, pprefix):
919
920 # parent items
921 p_dns_servers = []
922 p_tftpd_server = None
923 p_ntp_servers = []
924
925 # scan parent prefix for services
926 for ip, ao in pprefix.aos.items():
927
928 target_ip = str(netaddr.IPNetwork(ip).ip)
929
930 # add dns servers
931 if ao.has_service(ip, 53, "udp"):
932 p_dns_servers.append(target_ip)
933
934 # add tftp server
935 if ao.has_service(ip, 69, "udp"):
936 if not p_tftpd_server:
937 p_tftpd_server = target_ip
938 else:
939 logger.warning(
940 "Duplicate TFTP servers in parent prefix, using first of %s and %s",
941 p_tftpd_server,
942 target_ip,
943 )
944
945 # add NTP servers
946 if ao.has_service(ip, 123, "udp"):
947 p_ntp_servers.append(target_ip)
948
949 # merge if doesn't exist in prefix
950 if not self.dns_servers:
951 self.dns_servers = p_dns_servers
952
953 if not self.tftpd_server:
954 self.tftpd_server = p_tftpd_server
955
956 if not self.ntp_servers:
957 self.ntp_servers = p_ntp_servers
958
959 @classmethod
960 def to_yaml(cls, representer, node):
961 return representer.represent_dict(
962 {
963 "subnet": node.subnet,
964 "range": node.range,
965 "routers": node.routers,
966 "hosts": node.hosts,
967 "dns_servers": node.dns_servers,
968 "dns_search": node.dns_search,
969 "tftpd_server": node.tftpd_server,
970 "ntp_servers": node.ntp_servers,
971 }
972 )