blob: b941cfb0f7ae4be85504a8a390679a5171ee0a61 [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
Wei-Yu Chen55a86822021-07-08 14:34:59 +080050netboxapi = None
51netbox_config = None
52netbox_version = None
53
54
55def initialize(extra_args):
56 global netboxapi, netbox_config, netbox_version
57
58 args = parse_cli_args(extra_args)
59 netbox_config = yaml.safe_load(args.settings.read())
60
61 for require_args in ["api_endpoint", "token", "tenant_name"]:
62 if not netbox_config.get(require_args):
63 logger.error("The require argument: %s was not set. Stop." % require_args)
64 sys.exit(1)
65
66 netboxapi = pynetbox.api(
67 netbox_config["api_endpoint"], token=netbox_config["token"], threading=True,
68 )
69
70 if not netbox_config.get("validate_certs", True):
71 session = requests.Session()
72 session.verify = False
73 netboxapi.http_session = session
74
75 netbox_version = netboxapi.version
76
77 return args
78
Zack Williams2aeb3ef2021-06-11 17:10:36 -070079
80def parse_cli_args(extra_args={}):
81 """
82 parse CLI arguments. Can add extra arguments with a option:kwargs dict
83 """
84
85 parser = argparse.ArgumentParser(description="Netbox")
86
87 # Positional args
88 parser.add_argument(
89 "settings",
90 type=argparse.FileType("r"),
91 help="YAML Ansible inventory file w/NetBox API token",
92 )
93
94 parser.add_argument(
95 "--debug", action="store_true", help="Print additional debugging information"
96 )
97
Wei-Yu Chen55a86822021-07-08 14:34:59 +080098 for ename, ekwargs in extra_args.items():
99 parser.add_argument(ename, **ekwargs)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700100
101 args = parser.parse_args()
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800102 log_level = logging.DEBUG if args.debug else logging.INFO
103 logger.setLevel(log_level)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700104
105 return args
106
107
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800108def check_name_dns(name):
109
110 badchars = re.search("[^a-z0-9.-]", name.lower(), re.ASCII)
111
112 if badchars:
113 logger.error(
114 "DNS name '%s' has one or more invalid characters: '%s'",
115 name,
116 badchars.group(0),
117 )
118 sys.exit(1)
119
120 return name.lower()
121
122
123def clean_name_dns(name):
124 return re.sub("[^a-z0-9.-]", "-", name.lower(), 0, re.ASCII)
125
126
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700127class AttrDict(dict):
128 def __init__(self, *args, **kwargs):
129 super(AttrDict, self).__init__(*args, **kwargs)
130 self.__dict__ = self
131
132
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800133class NBTenant:
134 def __init__(self):
135 self.name = netbox_config["tenant_name"]
136 self.name_segments = netbox_config.get("prefix_segments", 1)
137 self.tenant = netboxapi.tenancy.tenants.get(name=self.name)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700138
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800139 # NBTenant only keep the resources which owns by it
140 self.devices = list()
141 self.vms = list()
142 self.prefixes = dict()
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700143
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800144 # Get the Device and Virtual Machines from Netbox API
145 for device_data in netboxapi.dcim.devices.filter(tenant=self.tenant.slug):
146 self.devices.append(NBDevice(device_data))
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700147
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800148 for vm_data in netboxapi.virtualization.virtual_machines.filter(
149 tenant=self.tenant.slug
150 ):
151 self.vms.append(NBVirtualMachine(vm_data))
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700152
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800153 def get_prefixes(self):
154 """Get the IP Prefixes owns by current tenant"""
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700155
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800156 if self.prefixes:
157 return self.prefixes
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700158
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800159 vrf = netboxapi.ipam.vrfs.get(tenant=self.tenant.slug)
160 for prefix_data in netboxapi.ipam.prefixes.filter(vrf_id=vrf.id):
161 if prefix_data.description:
162 self.prefixes[prefix_data.description] = NBPrefix(
163 prefix_data, self.name_segments
164 )
165
166 return self.prefixes
167
168 def generate_netplan(self, name=""):
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700169 """
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800170 Get the interface config of specific server belongs to this tenant,
171 If the name wasn't specified, return the management device config by default
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700172 """
173
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800174 target = None
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700175
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800176 if not name:
Wei-Yu Chenb43fc322021-07-21 14:50:16 +0800177 for machine in self.devices + self.vms:
178 if machine.data["device_role"]["name"] == "Router":
179 target = machine
180 break
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800181 else:
Wei-Yu Chenb43fc322021-07-21 14:50:16 +0800182 for machine in self.devices + self.vms:
183 if machine.name == name:
184 target = machine
185 break
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700186
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800187 return target.generate_netplan()
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700188
189
190@yaml.yaml_object(ydump)
191class NBPrefix:
192
193 prefixes = {}
194
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800195 def __init__(self, data, name_segments):
196 self.data = data
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700197 self.name_segments = name_segments
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800198 self.domain_extension = check_name_dns(self.data.description)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700199
200 logger.debug(
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800201 "Preix %s: domain_extension %s, data: %s",
202 self.data.prefix,
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700203 self.domain_extension,
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800204 dict(self.data),
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700205 )
206
207 # ip centric info
208 self.dhcp_range = None
209 self.reserved_ips = {}
210 self.aos = {}
211
212 # build item lists
213 self.build_prefix()
Wei-Yu Chen8ade3302021-07-19 20:53:47 +0800214 self.prefixes[self.data.prefix] = self
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700215
216 @classmethod
217 def all_prefixes(cls):
218 return cls.prefixes
219
220 @classmethod
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800221 def get_prefix(cls, prefix, name_segments=1):
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700222 if prefix in cls.prefixes:
223 return cls.prefixes[prefix]
224
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800225 data = netboxapi.ipam.prefixes.get(prefix=prefix)
226 if data:
227 return NBPrefix(data, name_segments)
228 else:
229 raise Exception("The prefix %s wasn't found in Netbox" % prefix)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700230
231 def __repr__(self):
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800232 return str(self.data.prefix)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700233
234 @classmethod
235 def to_yaml(cls, representer, node):
236 return representer.represent_dict(
237 {
238 "dhcp_range": node.dhcp_range,
239 "reserved_ips": node.reserved_ips,
240 "aos": node.aos,
241 "prefix_data": dict(node.prefix_data),
242 }
243 )
244
245 def parent(self):
246 """
247 Get the parent prefix to this prefix
248
249 FIXME: Doesn't handle multiple layers of prefixes, returns first found
250 """
251
252 # get all parents of this prefix (include self)
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800253 possible_parents = netboxapi.ipam.prefixes.filter(contains=self.data.prefix)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700254
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800255 logger.debug(
256 "Prefix %s: possible parents %s", self.data.prefix, possible_parents
257 )
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700258
259 # filter out self, return first found
260 for pparent in possible_parents:
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800261 if pparent.prefix != self.data.prefix:
262 return NBPrefix.get_prefix(pparent.prefix, self.name_segments)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700263
264 return None
265
266 def build_prefix(self):
267 """
268 find ip information for items (devices/vms, reserved_ips, dhcp_range) in prefix
269 """
270
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800271 ips = netboxapi.ipam.ip_addresses.filter(parent=self.data.prefix)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700272
273 for ip in sorted(ips, key=lambda k: k["address"]):
274
275 logger.debug("prefix_item ip: %s, data: %s", ip, dict(ip))
276
277 # if it's a DHCP range, add that range to the dev list as prefix_dhcp
278 if ip.status.value == "dhcp":
279 self.dhcp_range = str(ip.address)
280 continue
281
282 # reserved IPs
283 if ip.status.value == "reserved":
284
285 res = {}
286 res["name"] = ip.description.lower().split(" ")[0]
287 res["description"] = ip.description
288 res["ip4"] = str(netaddr.IPNetwork(ip.address))
289 res["custom_fields"] = ip.custom_fields
290
291 self.reserved_ips[str(ip)] = res
292 continue
293
294 # devices and VMs
295 if ip.assigned_object: # can be null if not assigned to a device/vm
296 aotype = ip.assigned_object_type
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700297 if aotype == "dcim.interface":
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800298 self.aos[str(ip)] = NBDevice.get_by_id(
299 ip.assigned_object.device.id,
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700300 )
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700301 elif aotype == "virtualization.vminterface":
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800302 self.aos[str(ip)] = NBVirtualMachine.get_by_id(
303 ip.assigned_object.virtual_machine.id,
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700304 )
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700305 else:
306 logger.error("IP %s has unknown device type: %s", ip, aotype)
307 sys.exit(1)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700308 else:
309 logger.warning("Unknown IP type %s, with attributes: %s", ip, dict(ip))
310
311
312@yaml.yaml_object(ydump)
313class NBAssignedObject:
314 """
315 Assigned Object is either a Device or Virtual Machine, which function
316 nearly identically in the NetBox data model.
317
318 This parent class holds common functions for those two child classes
319 """
320
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800321 objects = dict()
322
323 def __init__(self, data):
324 self.data = data
325
326 # The AssignedObject attributes
327 self.id = data.id
328 self.name = data.name
329 self.ips = dict()
330
331 # The NetBox objects related with this AssignedObject
332 self.services = None
333 self.interfaces = list()
334 self.mgmt_interfaces = list()
335 self.interfaces_by_ip = dict()
336
337 if self.__class__ == NBDevice:
338 self.interfaces = netboxapi.dcim.interfaces.filter(device_id=self.id)
339 self.services = netboxapi.ipam.services.filter(device_id=self.id)
340 ip_addresses = netboxapi.ipam.ip_addresses.filter(device_id=self.id)
341 elif self.__class__ == NBVirtualMachine:
342 self.interfaces = netboxapi.virtualization.interfaces.filter(
343 virtual_machine_id=self.id
344 )
345 self.services = netboxapi.ipam.services.filter(virtual_machine_id=self.id)
346 ip_addresses = netboxapi.ipam.ip_addresses.filter(
347 virtual_machine_id=self.id
348 )
349
350 for ip in ip_addresses:
351 self.ips[ip.address] = ip
352 if ip.assigned_object and self.__class__ == NBDevice:
353 self.interfaces_by_ip[ip.address] = netboxapi.dcim.interfaces.get(
354 ip.assigned_object_id
355 )
356 elif ip.assigned_object and self.__class__ == NBVirtualMachine:
357 self.interfaces_by_ip[
358 ip.address
359 ] = netboxapi.virtualization.interfaces.get(ip.assigned_object_id)
360 self.interfaces_by_ip[ip.address].mgmt_only = False
361
362 logger.debug(
363 "%s id: %d, data: %s, ips: %s"
364 % (self.type, self.id, dict(self.data), self.ips)
365 )
366
367 self.netplan_config = dict()
368
369 def __repr__(self):
370 return str(dict(self.data))
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700371
372 def dns_name(self, ip, prefix):
373 """
374 Returns the DNS name for the device at this IP in the prefix
375 """
376
377 def first_segment_suffix(split_name, suffixes, segments):
378 first_seg = "-".join([split_name[0], *suffixes])
379
380 if segments > 1:
381 name = ".".join([first_seg, *split_name[1:segments]])
382 else:
383 name = first_seg
384
385 return name
386
387 # clean/split the device name
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800388 name_split = clean_name_dns(self.data.name).split(".")
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700389
390 # always add interface suffix to mgmt interfaces
391 if self.interfaces_by_ip[ip].mgmt_only:
392 return first_segment_suffix(
393 name_split, [self.interfaces_by_ip[ip].name], prefix.name_segments
394 )
395
396 # find all IP's for this device in the prefix that aren't mgmt interfaces
397 prefix_ips = []
398 for s_ip in self.ips:
399 if s_ip in prefix.aos and not self.interfaces_by_ip[s_ip].mgmt_only:
400 prefix_ips.append(s_ip)
401
402 # name to use when only one IP address for device in a prefix
403 simple_name = ".".join(name_split[0 : prefix.name_segments])
404
405 # if more than one non-mgmt IP in prefix
406 if len(prefix_ips) > 1:
407
408 # use bare name if primary IP address
409 try: # skip if no primary_ip.address
410 if ip == self.data.primary_ip.address:
411 return simple_name
412 except AttributeError:
413 pass
414
415 # else, suffix with the interface name, and the last octet of IP address
416 return first_segment_suffix(
417 name_split,
418 [
419 self.interfaces_by_ip[ip].name,
420 str(netaddr.IPNetwork(ip).ip.words[3]),
421 ],
422 prefix.name_segments,
423 )
424
425 # simplest case - only one IP in prefix, return simple_name
426 return simple_name
427
428 def dns_cnames(self, ip):
429 """
430 returns a list of cnames for this object, based on IP matches
431 """
432
433 cnames = []
434
435 for service in self.services:
436
437 # if not assigned to any IP's, service is on all IPs
438 if not service.ipaddresses:
439 cnames.append(service.name)
440 continue
441
442 # If assigned to an IP, only create a CNAME on that IP
443 for service_ip in service.ipaddresses:
444 if ip == service_ip.address:
445 cnames.append(service.name)
446
447 return cnames
448
449 def has_service(self, cidr_ip, port, protocol):
450 """
451 Return True if this AO has a service using specific port and protocol combination
452 """
453
454 if (
455 cidr_ip in self.interfaces_by_ip
456 and not self.interfaces_by_ip[cidr_ip].mgmt_only
457 ):
458 for service in self.services:
459 if service.port == port and service.protocol.value == protocol:
460 return True
461
462 return False
463
464 def primary_iface(self):
465 """
466 Returns the interface data for the device that has the primary_ip
467 """
468
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800469 if self.data.primary_ip:
470 return self.interfaces_by_ip[self.data.primary_ip.address]
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700471
472 return None
473
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800474 @property
475 def type(self):
476 return "AssignedObject"
477
478 @classmethod
479 def get_by_id(cls, obj_id):
480 raise Exception("not implemented")
481
482 @classmethod
483 def all_objects(cls):
484 return cls.objects
485
486 @classmethod
487 def to_yaml(cls, representer, node):
488 return representer.represent_dict(
489 {
490 "data": node.data,
491 "services": node.services,
492 "ips": node.ips,
493 "interfaces_by_ip": node.interfaces_by_ip,
494 }
495 )
496
497 def generate_netplan(self):
498 """
499 Get the interface config of specific server belongs to this tenant
500 """
501
502 if self.netplan_config:
503 return self.netplan_config
504
505 if not self.data:
506 logger.error(
507 "{type} {name} doesn't have data yet.".format(
508 type=self.type, name=self.name
509 )
510 )
511 sys.exit(1)
512
513 primary_ip = self.data.primary_ip.address if self.data.primary_ip else None
514 primary_if = self.interfaces_by_ip[primary_ip] if primary_ip else None
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800515
516 self.netplan_config["ethernets"] = dict()
517
518 if self.data.device_role.name == "Router":
519 for address, interface in self.interfaces_by_ip.items():
Wei-Yu Chenb43fc322021-07-21 14:50:16 +0800520 if interface.mgmt_only is True or str(interface.type) == "Virtual":
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800521 continue
522
523 self.netplan_config["ethernets"].setdefault(interface.name, {})
524 self.netplan_config["ethernets"][interface.name].setdefault(
525 "addresses", []
526 ).append(address)
527
528 elif self.data.device_role.name == "Server":
529 if primary_if:
530 self.netplan_config["ethernets"][primary_if.name] = {
531 "dhcp4": "yes",
532 "dhcp4-overrides": {"route-metric": 100},
533 }
534
Wei-Yu Chenb43fc322021-07-21 14:50:16 +0800535 for physical_if in filter(
536 lambda i: str(i.type) != "Virtual"
537 and i != primary_if
538 and i.mgmt_only is False,
539 self.interfaces,
540 ):
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800541 self.netplan_config["ethernets"][physical_if.name] = {
542 "dhcp4": "yes",
543 "dhcp4-overrides": {"route-metric": 200},
544 }
545 else:
546 # Exclude the device type which is not Router and Server
547 return None
548
549 # Get interfaces own by AssignedObject and is virtual (VLAN interface)
Wei-Yu Chenb43fc322021-07-21 14:50:16 +0800550 for virtual_if in filter(lambda i: str(i.type) == "Virtual", self.interfaces):
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800551 if "vlans" not in self.netplan_config:
552 self.netplan_config["vlans"] = dict()
553
554 # vlan_object_id is the "id" on netbox, it's different from known VLAN ID
555 vlan_object_id = virtual_if.tagged_vlans[0].id
556 vlan_object = netboxapi.ipam.vlans.get(vlan_object_id)
557 virtual_if_ips = netboxapi.ipam.ip_addresses.filter(
558 interface_id=virtual_if.id
559 )
560
561 self.netplan_config["vlans"][virtual_if.name] = {
562 "id": vlan_object.vid,
563 "link": virtual_if.label,
564 "addresses": [ip.address for ip in virtual_if_ips],
565 }
566
Wei-Yu Chen8ade3302021-07-19 20:53:47 +0800567 # If the object is mgmtserver, it needs to have DNS/NTP server configs
568 if self.data["device_role"]["name"] == "Router":
569 services = list(netboxapi.ipam.services.filter(device_id=self.id))
570 service_names = list(map(lambda x: x.name, services))
571
572 if "dns" in service_names:
573 unbound_listen_ips = []
574 unbound_allow_ips = []
575
576 for ip, intf in self.interfaces_by_ip.items():
577 if ip != primary_ip and intf.mgmt_only == False:
578 unbound_listen_ips.append(ip)
579
580 for prefix in NBPrefix.all_prefixes().values():
581 if prefix.data.description:
582 unbound_allow_ips.append(prefix.data.prefix)
583 ntp_client_allow.append(prefix.data.prefix)
584
585 if unbound_listen_ips:
586 self.netplan_config["unbound_listen_ips"] = unbound_listen_ips
587
588 if unbound_allow_ips:
589 self.netplan_config["unbound_allow_ips"] = unbound_allow_ips
590
591 if "ntp" in service_names:
592 ntp_client_allow = []
593
594 for prefix in NBPrefix.all_prefixes().values():
595 if prefix.data.description:
596 ntp_client_allow.append(prefix.data.prefix)
597
598 if ntp_client_allow:
599 self.netplan_config["ntp_client_allow"] = ntp_client_allow
600
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800601 return self.netplan_config
602
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700603
604@yaml.yaml_object(ydump)
605class NBDevice(NBAssignedObject):
606 """
607 Wraps a single Netbox device
608 Also caches all known devices in a class variable (devs)
609 """
610
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800611 objects = dict()
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700612
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800613 def __init__(self, data):
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700614
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800615 super().__init__(data)
616 self.objects[self.id] = self
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700617
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800618 @property
619 def type(self):
620 return "NBDevice"
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700621
622 def get_interfaces(self):
623 if not self.interfaces:
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800624 self.interfaces = netboxapi.dcim.interfaces.filter(device_id=self.id)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700625
626 return self.interfaces
627
628 @classmethod
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800629 def get_by_id(cls, obj_id):
630 obj = cls.objects.get(obj_id, None)
631 obj = obj or NBDevice(netboxapi.dcim.devices.get(obj_id))
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700632
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800633 return obj
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700634
635
636@yaml.yaml_object(ydump)
637class NBVirtualMachine(NBAssignedObject):
638 """
639 VM equivalent of NBDevice
640 """
641
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800642 objects = dict()
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700643
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800644 def __init__(self, data):
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700645
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800646 super().__init__(data)
647 self.objects[self.id] = self
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700648
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800649 @property
650 def type(self):
651 return "NBVirtualMachine"
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700652
653 def get_interfaces(self):
654 if not self.interfaces:
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800655 self.interfaces = netboxapi.virtualization.interfaces.filter(
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700656 virtual_machine_id=self.id
657 )
658
659 return self.interfaces
660
661 @classmethod
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800662 def get_by_id(cls, obj_id):
663 obj = cls.objects.get(obj_id, None)
664 obj = obj or NBVirtualMachine(
665 netboxapi.virtualization.virtual_machines.get(obj_id)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700666 )
667
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800668 return obj
669
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700670
671@yaml.yaml_object(ydump)
672class NBDNSForwardZone:
673
674 fwd_zones = {}
675
676 def __init__(self, prefix):
677
678 self.domain_extension = prefix.domain_extension
679
680 self.a_recs = {}
681 self.cname_recs = {}
682 self.srv_recs = {}
683 self.ns_recs = []
684 self.txt_recs = {}
685
686 if prefix.dhcp_range:
687 self.create_dhcp_fwd(prefix.dhcp_range)
688
689 for ip, ao in prefix.aos.items():
690 self.add_ao_records(prefix, ip, ao)
691
692 for ip, res in prefix.reserved_ips.items():
693 self.add_reserved(ip, res)
694
695 # reqquired for the add_fwd_cname function below
696 if callable(getattr(prefix, "parent")):
697 parent_prefix = prefix.parent()
698
699 if parent_prefix:
700 self.merge_parent_prefix(parent_prefix, prefix)
701
702 self.fwd_zones[self.domain_extension] = self
703
704 def __repr__(self):
705 return str(
706 {
707 "a": self.a_recs,
708 "cname": self.cname_recs,
709 "ns": self.ns_recs,
710 "srv": self.srv_recs,
711 "txt": self.txt_recs,
712 }
713 )
714
715 @classmethod
716 def add_fwd_cname(cls, cname, fqdn_dest):
717 """
718 Add an arbitrary CNAME (and possibly create the fwd zone if needed) pointing
719 at a FQDN destination name. It's used to support the per-IP "DNS name" field in NetBox
720 Note that the NS record
721 """
722
723 try:
724 fqdn_split = re.compile(r"([a-z]+)\.([a-z.]+)\.")
725 (short_name, extension) = fqdn_split.match(cname).groups()
726
727 except AttributeError:
728 logger.warning(
729 "Invalid DNS CNAME: '%s', must be in FQDN format: 'host.example.com.', ignored",
730 cname,
731 )
732 return
733
734 fake_prefix = AttrDict(
735 {
736 "domain_extension": extension,
737 "dhcp_range": None,
738 "aos": {},
739 "reserved_ips": {},
740 "parent": None,
741 }
742 )
743
744 fwd_zone = cls.get_fwd_zone(fake_prefix)
745
746 fwd_zone.cname_recs[short_name] = fqdn_dest
747
748 @classmethod
749 def get_fwd_zone(cls, prefix):
750 if prefix.domain_extension in cls.fwd_zones:
751 return cls.fwd_zones[prefix.domain_extension]
752
753 return NBDNSForwardZone(prefix)
754
755 @classmethod
756 def all_fwd_zones(cls):
757 return cls.fwd_zones
758
759 @classmethod
760 def to_yaml(cls, representer, node):
761 return representer.represent_dict(
762 {
763 "a": node.a_recs,
764 "cname": node.cname_recs,
765 "ns": node.ns_recs,
766 "srv": node.srv_recs,
767 "txt": node.txt_recs,
768 }
769 )
770
771 def fqdn(self, name):
772 return "%s.%s." % (name, self.domain_extension)
773
774 def create_dhcp_fwd(self, dhcp_range):
775
776 for ip in netaddr.IPNetwork(dhcp_range).iter_hosts():
777 self.a_recs["dhcp%03d" % (ip.words[3])] = str(ip)
778
779 def name_is_duplicate(self, name, target, record_type):
780 """
781 Returns True if name already exists in the zone as an A or CNAME
782 record, False otherwise
783 """
784
785 if name in self.a_recs:
786 logger.warning(
787 "Duplicate DNS record for name %s - A record to '%s', %s record to '%s'",
788 name,
789 self.a_recs[name],
790 record_type,
791 target,
792 )
793 return True
794
795 if name in self.cname_recs:
796 logger.warning(
797 "Duplicate DNS record for name %s - CNAME record to '%s', %s record to '%s'",
798 name,
799 self.cname_recs[name],
800 record_type,
801 target,
802 )
803 return True
804
805 return False
806
807 def add_ao_records(self, prefix, ip, ao):
808
809 name = ao.dns_name(ip, prefix)
810 target_ip = str(netaddr.IPNetwork(ip).ip) # make bare IP, not CIDR format
811
812 # add A records
813 if not self.name_is_duplicate(name, target_ip, "A"):
814 self.a_recs[name] = target_ip
815
816 # add CNAME records that alias to this name
817 for cname in ao.dns_cnames(ip):
818 # check that it isn't a dupe
819 if not self.name_is_duplicate(cname, target_ip, "CNAME"):
820 self.cname_recs[cname] = self.fqdn(name)
821
822 # add NS records if this is a DNS server
823 if ao.has_service(ip, 53, "udp"):
824 self.ns_recs.append(self.fqdn(name))
825
826 # if a DNS name is set, add it as a CNAME
827 if ao.ips[ip]["dns_name"]: # and ip == aos.data.primary_ip.address:
828 self.add_fwd_cname(ao.ips[ip]["dns_name"], self.fqdn(name))
829
830 def add_reserved(self, ip, res):
831
832 target_ip = str(netaddr.IPNetwork(ip).ip) # make bare IP, not CIDR format
833
834 if not self.name_is_duplicate(res["name"], target_ip, "A"):
835 self.a_recs[res["name"]] = target_ip
836
837 def merge_parent_prefix(self, pprefix, prefix):
838
839 # only if no NS records exist already
840 if not self.ns_recs:
841 # scan parent prefix for services
842 for ip, ao in pprefix.aos.items():
843
844 # Create a DNS within this prefix pointing to out-of-prefix IP
845 # where DNS server is
846 name = ao.dns_name(ip, prefix)
847 target_ip = str(
848 netaddr.IPNetwork(ip).ip
849 ) # make bare IP, not CIDR format
850
851 # add NS records if this is a DNS server
852 if ao.has_service(ip, 53, "udp"):
853 self.a_recs[name] = target_ip
854 self.ns_recs.append(self.fqdn(name))
855
856
857@yaml.yaml_object(ydump)
858class NBDNSReverseZones:
859 def __init__(self):
860
861 self.reverse_zones = {}
862
863 @classmethod
864 def to_yaml(cls, representer, node):
865 return representer.represent_dict(node.reverse_zones)
866
867 @classmethod
868 def canonicalize_rfc1918_prefix(cls, prefix):
869 """
870 RFC1918 prefixes need to be expanded to their widest canonical range to
871 group all reverse lookup domains together for reverse DNS with NSD/Unbound.
872 """
873
874 pnet = netaddr.IPNetwork(str(prefix))
875 (o1, o2, o3, o4) = pnet.network.words # Split ipv4 octets
876 cidr_plen = pnet.prefixlen
877
878 if o1 == 10:
879 o2 = o3 = o4 = 0
880 cidr_plen = 8
881 elif (o1 == 172 and o2 >= 16 and o2 <= 31) or (o1 == 192 and o2 == 168):
882 o3 = o4 = 0
883 cidr_plen = 16
884
885 return "%s/%d" % (".".join(map(str, [o1, o2, o3, o4])), cidr_plen)
886
887 def add_prefix(self, prefix):
888
889 canonical_prefix = self.canonicalize_rfc1918_prefix(prefix)
890
891 if canonical_prefix in self.reverse_zones:
892 rzone = self.reverse_zones[canonical_prefix]
893 else:
894 rzone = {
895 "ns": [],
896 "ptr": {},
897 }
898
899 if prefix.dhcp_range:
900 # FIXME: doesn't check for duplicate entries
901 rzone["ptr"].update(self.create_dhcp_rev(prefix))
902
903 for ip, ao in prefix.aos.items():
904 target_ip = str(netaddr.IPNetwork(ip).ip) # make bare IP, not CIDR format
905 ao_name = self.get_ao_name(ip, ao, prefix,)
906 rzone["ptr"][target_ip] = ao_name
907
908 # add NS records if this is a DNS server
909 if ao.has_service(ip, 53, "udp"):
910 rzone["ns"].append(ao_name)
911
912 parent_prefix = prefix.parent()
913
914 if parent_prefix:
915 self.merge_parent_prefix(rzone, parent_prefix)
916
917 self.reverse_zones[canonical_prefix] = rzone
918
919 def merge_parent_prefix(self, rzone, pprefix):
920
921 # parent items
922 p_ns = []
923
924 # scan parent prefix for services
925 for ip, ao in pprefix.aos.items():
926
927 ao_name = self.get_ao_name(ip, ao, pprefix,)
928
929 # add NS records if this is a DNS server
930 if ao.has_service(ip, 53, "udp"):
931 p_ns.append(ao_name)
932
933 # set DNS servers if none in rzone
934 if not rzone["ns"]:
935 rzone["ns"] = p_ns
936
937 def create_dhcp_rev(self, prefix):
938
939 dhcp_rzone = {}
940
941 for ip in netaddr.IPNetwork(prefix.dhcp_range).iter_hosts():
942 dhcp_rzone[str(ip)] = "dhcp%03d.%s." % (
943 ip.words[3],
944 prefix.domain_extension,
945 )
946
947 return dhcp_rzone
948
949 def get_ao_name(self, ip, ao, prefix):
950 short_name = ao.dns_name(ip, prefix)
951 return "%s.%s." % (short_name, prefix.domain_extension)
952
953
954@yaml.yaml_object(ydump)
955class NBDHCPSubnet:
956 def __init__(self, prefix):
957
958 self.domain_extension = prefix.domain_extension
959
960 self.subnet = None
961 self.range = None
962 self.first_ip = None
963 self.hosts = []
964 self.routers = []
965 self.dns_servers = []
966 self.dns_search = []
967 self.tftpd_server = None
968 self.ntp_servers = []
969 self.dhcpd_interface = None
970
971 self.add_prefix(prefix)
972
973 for ip, ao in prefix.aos.items():
974 self.add_ao(str(ip), ao, prefix)
975
976 parent_prefix = prefix.parent()
977
978 if parent_prefix:
979 self.merge_parent_prefix(parent_prefix)
980
981 def add_prefix(self, prefix):
982
983 self.subnet = str(prefix)
984
985 self.first_ip = str(netaddr.IPAddress(netaddr.IPNetwork(str(prefix)).first + 1))
986
987 self.dns_search = [prefix.domain_extension]
988
989 if prefix.dhcp_range:
990 self.range = prefix.dhcp_range
991
992 for ip, res in prefix.reserved_ips.items():
993 # routers are reserved IP's that start with 'router" in the IP description
994 if re.match("router", res["description"]):
995 router = {"ip": str(netaddr.IPNetwork(ip).ip)}
996
997 if (
998 "rfc3442routes" in res["custom_fields"]
999 and res["custom_fields"]["rfc3442routes"]
1000 ):
1001 # split on whitespace
1002 router["rfc3442routes"] = re.split(
1003 r"\s+", res["custom_fields"]["rfc3442routes"]
1004 )
1005
1006 self.routers.append(router)
1007
1008 # set first IP to router if not set otherwise.
1009 if not self.routers:
1010 router = {"ip": self.first_ip}
1011
1012 self.routers.append(router)
1013
1014 def add_ao(self, ip, ao, prefix):
1015
1016 target_ip = str(netaddr.IPNetwork(ip).ip) # make bare IP, not CIDR format
1017
1018 # find the DHCP interface if it's this IP
1019 if target_ip == self.first_ip:
1020 self.dhcpd_interface = ao.interfaces_by_ip[ip].name
1021
1022 name = ao.dns_name(ip, prefix)
1023
1024 # add only devices that have a macaddr for this IP
1025 if ip in ao.interfaces_by_ip:
1026
1027 mac_addr = dict(ao.interfaces_by_ip[ip]).get("mac_address")
1028
1029 if mac_addr and mac_addr.strip(): # if exists and not blank
1030 self.hosts.append(
Wei-Yu Chenb43fc322021-07-21 14:50:16 +08001031 {"name": name, "ip_addr": target_ip, "mac_addr": mac_addr.lower()}
Zack Williams2aeb3ef2021-06-11 17:10:36 -07001032 )
1033
1034 # add dns servers
1035 if ao.has_service(ip, 53, "udp"):
1036 self.dns_servers.append(target_ip)
1037
1038 # add tftp server
1039 if ao.has_service(ip, 69, "udp"):
1040 if not self.tftpd_server:
1041 self.tftpd_server = target_ip
1042 else:
1043 logger.warning(
1044 "Duplicate TFTP servers in prefix, using first of %s and %s",
1045 self.tftpd_server,
1046 target_ip,
1047 )
1048
1049 # add NTP servers
1050 if ao.has_service(ip, 123, "udp"):
1051 self.ntp_servers.append(target_ip)
1052
1053 def merge_parent_prefix(self, pprefix):
1054
1055 # parent items
1056 p_dns_servers = []
1057 p_tftpd_server = None
1058 p_ntp_servers = []
1059
1060 # scan parent prefix for services
1061 for ip, ao in pprefix.aos.items():
1062
1063 target_ip = str(netaddr.IPNetwork(ip).ip)
1064
1065 # add dns servers
1066 if ao.has_service(ip, 53, "udp"):
1067 p_dns_servers.append(target_ip)
1068
1069 # add tftp server
1070 if ao.has_service(ip, 69, "udp"):
1071 if not p_tftpd_server:
1072 p_tftpd_server = target_ip
1073 else:
1074 logger.warning(
1075 "Duplicate TFTP servers in parent prefix, using first of %s and %s",
1076 p_tftpd_server,
1077 target_ip,
1078 )
1079
1080 # add NTP servers
1081 if ao.has_service(ip, 123, "udp"):
1082 p_ntp_servers.append(target_ip)
1083
1084 # merge if doesn't exist in prefix
1085 if not self.dns_servers:
1086 self.dns_servers = p_dns_servers
1087
1088 if not self.tftpd_server:
1089 self.tftpd_server = p_tftpd_server
1090
1091 if not self.ntp_servers:
1092 self.ntp_servers = p_ntp_servers
1093
1094 @classmethod
1095 def to_yaml(cls, representer, node):
1096 return representer.represent_dict(
1097 {
1098 "subnet": node.subnet,
1099 "range": node.range,
1100 "routers": node.routers,
1101 "hosts": node.hosts,
1102 "dns_servers": node.dns_servers,
1103 "dns_search": node.dns_search,
1104 "tftpd_server": node.tftpd_server,
1105 "ntp_servers": node.ntp_servers,
1106 }
1107 )