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