blob: 8b540dda0ead688db9a0bf3e62d96e0ae881bd54 [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:
177 target = next(
178 filter(
179 lambda m: m.data["device_role"]["name"] == "Router",
180 self.devices + self.vms,
181 )
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700182 )
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800183 else:
184 target = next(filter(lambda m: m.name == name, self.devices + self.vms))
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700185
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800186 return target.generate_netplan()
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700187
188
189@yaml.yaml_object(ydump)
190class NBPrefix:
191
192 prefixes = {}
193
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800194 def __init__(self, data, name_segments):
195 self.data = data
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700196 self.name_segments = name_segments
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800197 self.domain_extension = check_name_dns(self.data.description)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700198
199 logger.debug(
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800200 "Preix %s: domain_extension %s, data: %s",
201 self.data.prefix,
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700202 self.domain_extension,
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800203 dict(self.data),
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700204 )
205
206 # ip centric info
207 self.dhcp_range = None
208 self.reserved_ips = {}
209 self.aos = {}
210
211 # build item lists
212 self.build_prefix()
213
214 @classmethod
215 def all_prefixes(cls):
216 return cls.prefixes
217
218 @classmethod
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800219 def get_prefix(cls, prefix, name_segments=1):
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700220 if prefix in cls.prefixes:
221 return cls.prefixes[prefix]
222
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800223 data = netboxapi.ipam.prefixes.get(prefix=prefix)
224 if data:
225 return NBPrefix(data, name_segments)
226 else:
227 raise Exception("The prefix %s wasn't found in Netbox" % prefix)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700228
229 def __repr__(self):
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800230 return str(self.data.prefix)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700231
232 @classmethod
233 def to_yaml(cls, representer, node):
234 return representer.represent_dict(
235 {
236 "dhcp_range": node.dhcp_range,
237 "reserved_ips": node.reserved_ips,
238 "aos": node.aos,
239 "prefix_data": dict(node.prefix_data),
240 }
241 )
242
243 def parent(self):
244 """
245 Get the parent prefix to this prefix
246
247 FIXME: Doesn't handle multiple layers of prefixes, returns first found
248 """
249
250 # get all parents of this prefix (include self)
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800251 possible_parents = netboxapi.ipam.prefixes.filter(contains=self.data.prefix)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700252
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800253 logger.debug(
254 "Prefix %s: possible parents %s", self.data.prefix, possible_parents
255 )
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700256
257 # filter out self, return first found
258 for pparent in possible_parents:
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800259 if pparent.prefix != self.data.prefix:
260 return NBPrefix.get_prefix(pparent.prefix, self.name_segments)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700261
262 return None
263
264 def build_prefix(self):
265 """
266 find ip information for items (devices/vms, reserved_ips, dhcp_range) in prefix
267 """
268
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800269 ips = netboxapi.ipam.ip_addresses.filter(parent=self.data.prefix)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700270
271 for ip in sorted(ips, key=lambda k: k["address"]):
272
273 logger.debug("prefix_item ip: %s, data: %s", ip, dict(ip))
274
275 # if it's a DHCP range, add that range to the dev list as prefix_dhcp
276 if ip.status.value == "dhcp":
277 self.dhcp_range = str(ip.address)
278 continue
279
280 # reserved IPs
281 if ip.status.value == "reserved":
282
283 res = {}
284 res["name"] = ip.description.lower().split(" ")[0]
285 res["description"] = ip.description
286 res["ip4"] = str(netaddr.IPNetwork(ip.address))
287 res["custom_fields"] = ip.custom_fields
288
289 self.reserved_ips[str(ip)] = res
290 continue
291
292 # devices and VMs
293 if ip.assigned_object: # can be null if not assigned to a device/vm
294 aotype = ip.assigned_object_type
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700295 if aotype == "dcim.interface":
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800296 self.aos[str(ip)] = NBDevice.get_by_id(
297 ip.assigned_object.device.id,
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700298 )
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700299 elif aotype == "virtualization.vminterface":
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800300 self.aos[str(ip)] = NBVirtualMachine.get_by_id(
301 ip.assigned_object.virtual_machine.id,
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700302 )
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700303 else:
304 logger.error("IP %s has unknown device type: %s", ip, aotype)
305 sys.exit(1)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700306 else:
307 logger.warning("Unknown IP type %s, with attributes: %s", ip, dict(ip))
308
309
310@yaml.yaml_object(ydump)
311class NBAssignedObject:
312 """
313 Assigned Object is either a Device or Virtual Machine, which function
314 nearly identically in the NetBox data model.
315
316 This parent class holds common functions for those two child classes
317 """
318
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800319 objects = dict()
320
321 def __init__(self, data):
322 self.data = data
323
324 # The AssignedObject attributes
325 self.id = data.id
326 self.name = data.name
327 self.ips = dict()
328
329 # The NetBox objects related with this AssignedObject
330 self.services = None
331 self.interfaces = list()
332 self.mgmt_interfaces = list()
333 self.interfaces_by_ip = dict()
334
335 if self.__class__ == NBDevice:
336 self.interfaces = netboxapi.dcim.interfaces.filter(device_id=self.id)
337 self.services = netboxapi.ipam.services.filter(device_id=self.id)
338 ip_addresses = netboxapi.ipam.ip_addresses.filter(device_id=self.id)
339 elif self.__class__ == NBVirtualMachine:
340 self.interfaces = netboxapi.virtualization.interfaces.filter(
341 virtual_machine_id=self.id
342 )
343 self.services = netboxapi.ipam.services.filter(virtual_machine_id=self.id)
344 ip_addresses = netboxapi.ipam.ip_addresses.filter(
345 virtual_machine_id=self.id
346 )
347
348 for ip in ip_addresses:
349 self.ips[ip.address] = ip
350 if ip.assigned_object and self.__class__ == NBDevice:
351 self.interfaces_by_ip[ip.address] = netboxapi.dcim.interfaces.get(
352 ip.assigned_object_id
353 )
354 elif ip.assigned_object and self.__class__ == NBVirtualMachine:
355 self.interfaces_by_ip[
356 ip.address
357 ] = netboxapi.virtualization.interfaces.get(ip.assigned_object_id)
358 self.interfaces_by_ip[ip.address].mgmt_only = False
359
360 logger.debug(
361 "%s id: %d, data: %s, ips: %s"
362 % (self.type, self.id, dict(self.data), self.ips)
363 )
364
365 self.netplan_config = dict()
366
367 def __repr__(self):
368 return str(dict(self.data))
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700369
370 def dns_name(self, ip, prefix):
371 """
372 Returns the DNS name for the device at this IP in the prefix
373 """
374
375 def first_segment_suffix(split_name, suffixes, segments):
376 first_seg = "-".join([split_name[0], *suffixes])
377
378 if segments > 1:
379 name = ".".join([first_seg, *split_name[1:segments]])
380 else:
381 name = first_seg
382
383 return name
384
385 # clean/split the device name
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800386 name_split = clean_name_dns(self.data.name).split(".")
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700387
388 # always add interface suffix to mgmt interfaces
389 if self.interfaces_by_ip[ip].mgmt_only:
390 return first_segment_suffix(
391 name_split, [self.interfaces_by_ip[ip].name], prefix.name_segments
392 )
393
394 # find all IP's for this device in the prefix that aren't mgmt interfaces
395 prefix_ips = []
396 for s_ip in self.ips:
397 if s_ip in prefix.aos and not self.interfaces_by_ip[s_ip].mgmt_only:
398 prefix_ips.append(s_ip)
399
400 # name to use when only one IP address for device in a prefix
401 simple_name = ".".join(name_split[0 : prefix.name_segments])
402
403 # if more than one non-mgmt IP in prefix
404 if len(prefix_ips) > 1:
405
406 # use bare name if primary IP address
407 try: # skip if no primary_ip.address
408 if ip == self.data.primary_ip.address:
409 return simple_name
410 except AttributeError:
411 pass
412
413 # else, suffix with the interface name, and the last octet of IP address
414 return first_segment_suffix(
415 name_split,
416 [
417 self.interfaces_by_ip[ip].name,
418 str(netaddr.IPNetwork(ip).ip.words[3]),
419 ],
420 prefix.name_segments,
421 )
422
423 # simplest case - only one IP in prefix, return simple_name
424 return simple_name
425
426 def dns_cnames(self, ip):
427 """
428 returns a list of cnames for this object, based on IP matches
429 """
430
431 cnames = []
432
433 for service in self.services:
434
435 # if not assigned to any IP's, service is on all IPs
436 if not service.ipaddresses:
437 cnames.append(service.name)
438 continue
439
440 # If assigned to an IP, only create a CNAME on that IP
441 for service_ip in service.ipaddresses:
442 if ip == service_ip.address:
443 cnames.append(service.name)
444
445 return cnames
446
447 def has_service(self, cidr_ip, port, protocol):
448 """
449 Return True if this AO has a service using specific port and protocol combination
450 """
451
452 if (
453 cidr_ip in self.interfaces_by_ip
454 and not self.interfaces_by_ip[cidr_ip].mgmt_only
455 ):
456 for service in self.services:
457 if service.port == port and service.protocol.value == protocol:
458 return True
459
460 return False
461
462 def primary_iface(self):
463 """
464 Returns the interface data for the device that has the primary_ip
465 """
466
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800467 if self.data.primary_ip:
468 return self.interfaces_by_ip[self.data.primary_ip.address]
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700469
470 return None
471
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800472 @property
473 def type(self):
474 return "AssignedObject"
475
476 @classmethod
477 def get_by_id(cls, obj_id):
478 raise Exception("not implemented")
479
480 @classmethod
481 def all_objects(cls):
482 return cls.objects
483
484 @classmethod
485 def to_yaml(cls, representer, node):
486 return representer.represent_dict(
487 {
488 "data": node.data,
489 "services": node.services,
490 "ips": node.ips,
491 "interfaces_by_ip": node.interfaces_by_ip,
492 }
493 )
494
495 def generate_netplan(self):
496 """
497 Get the interface config of specific server belongs to this tenant
498 """
499
500 if self.netplan_config:
501 return self.netplan_config
502
503 if not self.data:
504 logger.error(
505 "{type} {name} doesn't have data yet.".format(
506 type=self.type, name=self.name
507 )
508 )
509 sys.exit(1)
510
511 primary_ip = self.data.primary_ip.address if self.data.primary_ip else None
512 primary_if = self.interfaces_by_ip[primary_ip] if primary_ip else None
513 physical_ifs = filter(
514 lambda i: str(i.type) != "Virtual"
515 and i != primary_if
516 and i.mgmt_only == False,
517 self.interfaces,
518 )
519
520 self.netplan_config["ethernets"] = dict()
521
522 if self.data.device_role.name == "Router":
523 for address, interface in self.interfaces_by_ip.items():
524 if interface.mgmt_only == True or str(interface.type) == "Virtual":
525 continue
526
527 self.netplan_config["ethernets"].setdefault(interface.name, {})
528 self.netplan_config["ethernets"][interface.name].setdefault(
529 "addresses", []
530 ).append(address)
531
532 elif self.data.device_role.name == "Server":
533 if primary_if:
534 self.netplan_config["ethernets"][primary_if.name] = {
535 "dhcp4": "yes",
536 "dhcp4-overrides": {"route-metric": 100},
537 }
538
539 for physical_if in physical_ifs:
540 self.netplan_config["ethernets"][physical_if.name] = {
541 "dhcp4": "yes",
542 "dhcp4-overrides": {"route-metric": 200},
543 }
544 else:
545 # Exclude the device type which is not Router and Server
546 return None
547
548 # Get interfaces own by AssignedObject and is virtual (VLAN interface)
549 virtual_ifs = filter(lambda i: str(i.type) == "Virtual", self.interfaces)
550
551 for virtual_if in virtual_ifs:
552 if "vlans" not in self.netplan_config:
553 self.netplan_config["vlans"] = dict()
554
555 # vlan_object_id is the "id" on netbox, it's different from known VLAN ID
556 vlan_object_id = virtual_if.tagged_vlans[0].id
557 vlan_object = netboxapi.ipam.vlans.get(vlan_object_id)
558 virtual_if_ips = netboxapi.ipam.ip_addresses.filter(
559 interface_id=virtual_if.id
560 )
561
562 self.netplan_config["vlans"][virtual_if.name] = {
563 "id": vlan_object.vid,
564 "link": virtual_if.label,
565 "addresses": [ip.address for ip in virtual_if_ips],
566 }
567
568 return self.netplan_config
569
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700570
571@yaml.yaml_object(ydump)
572class NBDevice(NBAssignedObject):
573 """
574 Wraps a single Netbox device
575 Also caches all known devices in a class variable (devs)
576 """
577
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800578 objects = dict()
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700579
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800580 def __init__(self, data):
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700581
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800582 super().__init__(data)
583 self.objects[self.id] = self
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700584
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800585 @property
586 def type(self):
587 return "NBDevice"
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700588
589 def get_interfaces(self):
590 if not self.interfaces:
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800591 self.interfaces = netboxapi.dcim.interfaces.filter(device_id=self.id)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700592
593 return self.interfaces
594
595 @classmethod
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800596 def get_by_id(cls, obj_id):
597 obj = cls.objects.get(obj_id, None)
598 obj = obj or NBDevice(netboxapi.dcim.devices.get(obj_id))
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700599
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800600 return obj
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700601
602
603@yaml.yaml_object(ydump)
604class NBVirtualMachine(NBAssignedObject):
605 """
606 VM equivalent of NBDevice
607 """
608
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800609 objects = dict()
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700610
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800611 def __init__(self, data):
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700612
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800613 super().__init__(data)
614 self.objects[self.id] = self
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700615
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800616 @property
617 def type(self):
618 return "NBVirtualMachine"
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700619
620 def get_interfaces(self):
621 if not self.interfaces:
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800622 self.interfaces = netboxapi.virtualization.interfaces.filter(
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700623 virtual_machine_id=self.id
624 )
625
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 NBVirtualMachine(
632 netboxapi.virtualization.virtual_machines.get(obj_id)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700633 )
634
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800635 return obj
636
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700637
638@yaml.yaml_object(ydump)
639class NBDNSForwardZone:
640
641 fwd_zones = {}
642
643 def __init__(self, prefix):
644
645 self.domain_extension = prefix.domain_extension
646
647 self.a_recs = {}
648 self.cname_recs = {}
649 self.srv_recs = {}
650 self.ns_recs = []
651 self.txt_recs = {}
652
653 if prefix.dhcp_range:
654 self.create_dhcp_fwd(prefix.dhcp_range)
655
656 for ip, ao in prefix.aos.items():
657 self.add_ao_records(prefix, ip, ao)
658
659 for ip, res in prefix.reserved_ips.items():
660 self.add_reserved(ip, res)
661
662 # reqquired for the add_fwd_cname function below
663 if callable(getattr(prefix, "parent")):
664 parent_prefix = prefix.parent()
665
666 if parent_prefix:
667 self.merge_parent_prefix(parent_prefix, prefix)
668
669 self.fwd_zones[self.domain_extension] = self
670
671 def __repr__(self):
672 return str(
673 {
674 "a": self.a_recs,
675 "cname": self.cname_recs,
676 "ns": self.ns_recs,
677 "srv": self.srv_recs,
678 "txt": self.txt_recs,
679 }
680 )
681
682 @classmethod
683 def add_fwd_cname(cls, cname, fqdn_dest):
684 """
685 Add an arbitrary CNAME (and possibly create the fwd zone if needed) pointing
686 at a FQDN destination name. It's used to support the per-IP "DNS name" field in NetBox
687 Note that the NS record
688 """
689
690 try:
691 fqdn_split = re.compile(r"([a-z]+)\.([a-z.]+)\.")
692 (short_name, extension) = fqdn_split.match(cname).groups()
693
694 except AttributeError:
695 logger.warning(
696 "Invalid DNS CNAME: '%s', must be in FQDN format: 'host.example.com.', ignored",
697 cname,
698 )
699 return
700
701 fake_prefix = AttrDict(
702 {
703 "domain_extension": extension,
704 "dhcp_range": None,
705 "aos": {},
706 "reserved_ips": {},
707 "parent": None,
708 }
709 )
710
711 fwd_zone = cls.get_fwd_zone(fake_prefix)
712
713 fwd_zone.cname_recs[short_name] = fqdn_dest
714
715 @classmethod
716 def get_fwd_zone(cls, prefix):
717 if prefix.domain_extension in cls.fwd_zones:
718 return cls.fwd_zones[prefix.domain_extension]
719
720 return NBDNSForwardZone(prefix)
721
722 @classmethod
723 def all_fwd_zones(cls):
724 return cls.fwd_zones
725
726 @classmethod
727 def to_yaml(cls, representer, node):
728 return representer.represent_dict(
729 {
730 "a": node.a_recs,
731 "cname": node.cname_recs,
732 "ns": node.ns_recs,
733 "srv": node.srv_recs,
734 "txt": node.txt_recs,
735 }
736 )
737
738 def fqdn(self, name):
739 return "%s.%s." % (name, self.domain_extension)
740
741 def create_dhcp_fwd(self, dhcp_range):
742
743 for ip in netaddr.IPNetwork(dhcp_range).iter_hosts():
744 self.a_recs["dhcp%03d" % (ip.words[3])] = str(ip)
745
746 def name_is_duplicate(self, name, target, record_type):
747 """
748 Returns True if name already exists in the zone as an A or CNAME
749 record, False otherwise
750 """
751
752 if name in self.a_recs:
753 logger.warning(
754 "Duplicate DNS record for name %s - A record to '%s', %s record to '%s'",
755 name,
756 self.a_recs[name],
757 record_type,
758 target,
759 )
760 return True
761
762 if name in self.cname_recs:
763 logger.warning(
764 "Duplicate DNS record for name %s - CNAME record to '%s', %s record to '%s'",
765 name,
766 self.cname_recs[name],
767 record_type,
768 target,
769 )
770 return True
771
772 return False
773
774 def add_ao_records(self, prefix, ip, ao):
775
776 name = ao.dns_name(ip, prefix)
777 target_ip = str(netaddr.IPNetwork(ip).ip) # make bare IP, not CIDR format
778
779 # add A records
780 if not self.name_is_duplicate(name, target_ip, "A"):
781 self.a_recs[name] = target_ip
782
783 # add CNAME records that alias to this name
784 for cname in ao.dns_cnames(ip):
785 # check that it isn't a dupe
786 if not self.name_is_duplicate(cname, target_ip, "CNAME"):
787 self.cname_recs[cname] = self.fqdn(name)
788
789 # add NS records if this is a DNS server
790 if ao.has_service(ip, 53, "udp"):
791 self.ns_recs.append(self.fqdn(name))
792
793 # if a DNS name is set, add it as a CNAME
794 if ao.ips[ip]["dns_name"]: # and ip == aos.data.primary_ip.address:
795 self.add_fwd_cname(ao.ips[ip]["dns_name"], self.fqdn(name))
796
797 def add_reserved(self, ip, res):
798
799 target_ip = str(netaddr.IPNetwork(ip).ip) # make bare IP, not CIDR format
800
801 if not self.name_is_duplicate(res["name"], target_ip, "A"):
802 self.a_recs[res["name"]] = target_ip
803
804 def merge_parent_prefix(self, pprefix, prefix):
805
806 # only if no NS records exist already
807 if not self.ns_recs:
808 # scan parent prefix for services
809 for ip, ao in pprefix.aos.items():
810
811 # Create a DNS within this prefix pointing to out-of-prefix IP
812 # where DNS server is
813 name = ao.dns_name(ip, prefix)
814 target_ip = str(
815 netaddr.IPNetwork(ip).ip
816 ) # make bare IP, not CIDR format
817
818 # add NS records if this is a DNS server
819 if ao.has_service(ip, 53, "udp"):
820 self.a_recs[name] = target_ip
821 self.ns_recs.append(self.fqdn(name))
822
823
824@yaml.yaml_object(ydump)
825class NBDNSReverseZones:
826 def __init__(self):
827
828 self.reverse_zones = {}
829
830 @classmethod
831 def to_yaml(cls, representer, node):
832 return representer.represent_dict(node.reverse_zones)
833
834 @classmethod
835 def canonicalize_rfc1918_prefix(cls, prefix):
836 """
837 RFC1918 prefixes need to be expanded to their widest canonical range to
838 group all reverse lookup domains together for reverse DNS with NSD/Unbound.
839 """
840
841 pnet = netaddr.IPNetwork(str(prefix))
842 (o1, o2, o3, o4) = pnet.network.words # Split ipv4 octets
843 cidr_plen = pnet.prefixlen
844
845 if o1 == 10:
846 o2 = o3 = o4 = 0
847 cidr_plen = 8
848 elif (o1 == 172 and o2 >= 16 and o2 <= 31) or (o1 == 192 and o2 == 168):
849 o3 = o4 = 0
850 cidr_plen = 16
851
852 return "%s/%d" % (".".join(map(str, [o1, o2, o3, o4])), cidr_plen)
853
854 def add_prefix(self, prefix):
855
856 canonical_prefix = self.canonicalize_rfc1918_prefix(prefix)
857
858 if canonical_prefix in self.reverse_zones:
859 rzone = self.reverse_zones[canonical_prefix]
860 else:
861 rzone = {
862 "ns": [],
863 "ptr": {},
864 }
865
866 if prefix.dhcp_range:
867 # FIXME: doesn't check for duplicate entries
868 rzone["ptr"].update(self.create_dhcp_rev(prefix))
869
870 for ip, ao in prefix.aos.items():
871 target_ip = str(netaddr.IPNetwork(ip).ip) # make bare IP, not CIDR format
872 ao_name = self.get_ao_name(ip, ao, prefix,)
873 rzone["ptr"][target_ip] = ao_name
874
875 # add NS records if this is a DNS server
876 if ao.has_service(ip, 53, "udp"):
877 rzone["ns"].append(ao_name)
878
879 parent_prefix = prefix.parent()
880
881 if parent_prefix:
882 self.merge_parent_prefix(rzone, parent_prefix)
883
884 self.reverse_zones[canonical_prefix] = rzone
885
886 def merge_parent_prefix(self, rzone, pprefix):
887
888 # parent items
889 p_ns = []
890
891 # scan parent prefix for services
892 for ip, ao in pprefix.aos.items():
893
894 ao_name = self.get_ao_name(ip, ao, pprefix,)
895
896 # add NS records if this is a DNS server
897 if ao.has_service(ip, 53, "udp"):
898 p_ns.append(ao_name)
899
900 # set DNS servers if none in rzone
901 if not rzone["ns"]:
902 rzone["ns"] = p_ns
903
904 def create_dhcp_rev(self, prefix):
905
906 dhcp_rzone = {}
907
908 for ip in netaddr.IPNetwork(prefix.dhcp_range).iter_hosts():
909 dhcp_rzone[str(ip)] = "dhcp%03d.%s." % (
910 ip.words[3],
911 prefix.domain_extension,
912 )
913
914 return dhcp_rzone
915
916 def get_ao_name(self, ip, ao, prefix):
917 short_name = ao.dns_name(ip, prefix)
918 return "%s.%s." % (short_name, prefix.domain_extension)
919
920
921@yaml.yaml_object(ydump)
922class NBDHCPSubnet:
923 def __init__(self, prefix):
924
925 self.domain_extension = prefix.domain_extension
926
927 self.subnet = None
928 self.range = None
929 self.first_ip = None
930 self.hosts = []
931 self.routers = []
932 self.dns_servers = []
933 self.dns_search = []
934 self.tftpd_server = None
935 self.ntp_servers = []
936 self.dhcpd_interface = None
937
938 self.add_prefix(prefix)
939
940 for ip, ao in prefix.aos.items():
941 self.add_ao(str(ip), ao, prefix)
942
943 parent_prefix = prefix.parent()
944
945 if parent_prefix:
946 self.merge_parent_prefix(parent_prefix)
947
948 def add_prefix(self, prefix):
949
950 self.subnet = str(prefix)
951
952 self.first_ip = str(netaddr.IPAddress(netaddr.IPNetwork(str(prefix)).first + 1))
953
954 self.dns_search = [prefix.domain_extension]
955
956 if prefix.dhcp_range:
957 self.range = prefix.dhcp_range
958
959 for ip, res in prefix.reserved_ips.items():
960 # routers are reserved IP's that start with 'router" in the IP description
961 if re.match("router", res["description"]):
962 router = {"ip": str(netaddr.IPNetwork(ip).ip)}
963
964 if (
965 "rfc3442routes" in res["custom_fields"]
966 and res["custom_fields"]["rfc3442routes"]
967 ):
968 # split on whitespace
969 router["rfc3442routes"] = re.split(
970 r"\s+", res["custom_fields"]["rfc3442routes"]
971 )
972
973 self.routers.append(router)
974
975 # set first IP to router if not set otherwise.
976 if not self.routers:
977 router = {"ip": self.first_ip}
978
979 self.routers.append(router)
980
981 def add_ao(self, ip, ao, prefix):
982
983 target_ip = str(netaddr.IPNetwork(ip).ip) # make bare IP, not CIDR format
984
985 # find the DHCP interface if it's this IP
986 if target_ip == self.first_ip:
987 self.dhcpd_interface = ao.interfaces_by_ip[ip].name
988
989 name = ao.dns_name(ip, prefix)
990
991 # add only devices that have a macaddr for this IP
992 if ip in ao.interfaces_by_ip:
993
994 mac_addr = dict(ao.interfaces_by_ip[ip]).get("mac_address")
995
996 if mac_addr and mac_addr.strip(): # if exists and not blank
997 self.hosts.append(
998 {"name": name, "ip_addr": target_ip, "mac_addr": mac_addr.lower(),}
999 )
1000
1001 # add dns servers
1002 if ao.has_service(ip, 53, "udp"):
1003 self.dns_servers.append(target_ip)
1004
1005 # add tftp server
1006 if ao.has_service(ip, 69, "udp"):
1007 if not self.tftpd_server:
1008 self.tftpd_server = target_ip
1009 else:
1010 logger.warning(
1011 "Duplicate TFTP servers in prefix, using first of %s and %s",
1012 self.tftpd_server,
1013 target_ip,
1014 )
1015
1016 # add NTP servers
1017 if ao.has_service(ip, 123, "udp"):
1018 self.ntp_servers.append(target_ip)
1019
1020 def merge_parent_prefix(self, pprefix):
1021
1022 # parent items
1023 p_dns_servers = []
1024 p_tftpd_server = None
1025 p_ntp_servers = []
1026
1027 # scan parent prefix for services
1028 for ip, ao in pprefix.aos.items():
1029
1030 target_ip = str(netaddr.IPNetwork(ip).ip)
1031
1032 # add dns servers
1033 if ao.has_service(ip, 53, "udp"):
1034 p_dns_servers.append(target_ip)
1035
1036 # add tftp server
1037 if ao.has_service(ip, 69, "udp"):
1038 if not p_tftpd_server:
1039 p_tftpd_server = target_ip
1040 else:
1041 logger.warning(
1042 "Duplicate TFTP servers in parent prefix, using first of %s and %s",
1043 p_tftpd_server,
1044 target_ip,
1045 )
1046
1047 # add NTP servers
1048 if ao.has_service(ip, 123, "udp"):
1049 p_ntp_servers.append(target_ip)
1050
1051 # merge if doesn't exist in prefix
1052 if not self.dns_servers:
1053 self.dns_servers = p_dns_servers
1054
1055 if not self.tftpd_server:
1056 self.tftpd_server = p_tftpd_server
1057
1058 if not self.ntp_servers:
1059 self.ntp_servers = p_ntp_servers
1060
1061 @classmethod
1062 def to_yaml(cls, representer, node):
1063 return representer.represent_dict(
1064 {
1065 "subnet": node.subnet,
1066 "range": node.range,
1067 "routers": node.routers,
1068 "hosts": node.hosts,
1069 "dns_servers": node.dns_servers,
1070 "dns_search": node.dns_search,
1071 "tftpd_server": node.tftpd_server,
1072 "ntp_servers": node.ntp_servers,
1073 }
1074 )