blob: c8309ab4d0f7fa1f80e7fb8aa8edefe23ad0f38f [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
Wei-Yu Chen6ca7bc12021-07-19 19:53:43 +0800216 self.prefixes[self.data.prefix] = self
217
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700218 @classmethod
219 def all_prefixes(cls):
220 return cls.prefixes
221
222 @classmethod
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800223 def get_prefix(cls, prefix, name_segments=1):
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700224 if prefix in cls.prefixes:
225 return cls.prefixes[prefix]
226
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800227 data = netboxapi.ipam.prefixes.get(prefix=prefix)
228 if data:
229 return NBPrefix(data, name_segments)
230 else:
231 raise Exception("The prefix %s wasn't found in Netbox" % prefix)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700232
233 def __repr__(self):
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800234 return str(self.data.prefix)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700235
236 @classmethod
237 def to_yaml(cls, representer, node):
238 return representer.represent_dict(
239 {
240 "dhcp_range": node.dhcp_range,
241 "reserved_ips": node.reserved_ips,
242 "aos": node.aos,
243 "prefix_data": dict(node.prefix_data),
244 }
245 )
246
Wei-Yu Chen6ca7bc12021-07-19 19:53:43 +0800247 @classmethod
248 def all_reserved_by_ip(cls, ip_addr=""):
249 """
250 all_reserved_by_ip will return all reserved IP found in prefixes
251
252 We have the IP address marked as type 'Reserved' in Prefix,
253 This type of IP address is using to define a DHCP range
254 """
255
256 ret = list()
257
258 for prefix in cls.prefixes.values():
259 if ip_addr and ip_addr in prefix.aos.keys():
260 if prefix.reserved_ips:
261 return list(prefix.reserved_ips.values())
262 else:
263 if prefix.reserved_ips:
264 ret.extend(list(prefix.reserved_ips.values()))
265
266 return ret
267
268 def get_reserved_ips(self):
269 """
270 Get the reserved IP range (DHCP) in prefix
271
272 We have the IP address marked as type 'Reserved' in Prefix,
273 This type of IP address is using to define a DHCP range
274 """
275 if prefix.reserved_ips:
276 return list(prefix.reserved_ips.values())
277
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700278 def parent(self):
279 """
280 Get the parent prefix to this prefix
281
282 FIXME: Doesn't handle multiple layers of prefixes, returns first found
283 """
284
285 # get all parents of this prefix (include self)
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800286 possible_parents = netboxapi.ipam.prefixes.filter(contains=self.data.prefix)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700287
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800288 logger.debug(
289 "Prefix %s: possible parents %s", self.data.prefix, possible_parents
290 )
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700291
292 # filter out self, return first found
293 for pparent in possible_parents:
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800294 if pparent.prefix != self.data.prefix:
295 return NBPrefix.get_prefix(pparent.prefix, self.name_segments)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700296
297 return None
298
299 def build_prefix(self):
300 """
301 find ip information for items (devices/vms, reserved_ips, dhcp_range) in prefix
302 """
303
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800304 ips = netboxapi.ipam.ip_addresses.filter(parent=self.data.prefix)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700305
306 for ip in sorted(ips, key=lambda k: k["address"]):
307
308 logger.debug("prefix_item ip: %s, data: %s", ip, dict(ip))
309
310 # if it's a DHCP range, add that range to the dev list as prefix_dhcp
311 if ip.status.value == "dhcp":
312 self.dhcp_range = str(ip.address)
313 continue
314
315 # reserved IPs
316 if ip.status.value == "reserved":
317
318 res = {}
319 res["name"] = ip.description.lower().split(" ")[0]
320 res["description"] = ip.description
321 res["ip4"] = str(netaddr.IPNetwork(ip.address))
322 res["custom_fields"] = ip.custom_fields
323
324 self.reserved_ips[str(ip)] = res
325 continue
326
327 # devices and VMs
328 if ip.assigned_object: # can be null if not assigned to a device/vm
329 aotype = ip.assigned_object_type
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700330 if aotype == "dcim.interface":
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800331 self.aos[str(ip)] = NBDevice.get_by_id(
332 ip.assigned_object.device.id,
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700333 )
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700334 elif aotype == "virtualization.vminterface":
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800335 self.aos[str(ip)] = NBVirtualMachine.get_by_id(
336 ip.assigned_object.virtual_machine.id,
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700337 )
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700338 else:
339 logger.error("IP %s has unknown device type: %s", ip, aotype)
340 sys.exit(1)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700341 else:
342 logger.warning("Unknown IP type %s, with attributes: %s", ip, dict(ip))
343
344
345@yaml.yaml_object(ydump)
346class NBAssignedObject:
347 """
348 Assigned Object is either a Device or Virtual Machine, which function
349 nearly identically in the NetBox data model.
350
351 This parent class holds common functions for those two child classes
352 """
353
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800354 objects = dict()
355
356 def __init__(self, data):
357 self.data = data
358
359 # The AssignedObject attributes
360 self.id = data.id
361 self.name = data.name
362 self.ips = dict()
363
364 # The NetBox objects related with this AssignedObject
365 self.services = None
366 self.interfaces = list()
367 self.mgmt_interfaces = list()
368 self.interfaces_by_ip = dict()
369
370 if self.__class__ == NBDevice:
371 self.interfaces = netboxapi.dcim.interfaces.filter(device_id=self.id)
372 self.services = netboxapi.ipam.services.filter(device_id=self.id)
373 ip_addresses = netboxapi.ipam.ip_addresses.filter(device_id=self.id)
374 elif self.__class__ == NBVirtualMachine:
375 self.interfaces = netboxapi.virtualization.interfaces.filter(
376 virtual_machine_id=self.id
377 )
378 self.services = netboxapi.ipam.services.filter(virtual_machine_id=self.id)
379 ip_addresses = netboxapi.ipam.ip_addresses.filter(
380 virtual_machine_id=self.id
381 )
382
383 for ip in ip_addresses:
384 self.ips[ip.address] = ip
385 if ip.assigned_object and self.__class__ == NBDevice:
386 self.interfaces_by_ip[ip.address] = netboxapi.dcim.interfaces.get(
387 ip.assigned_object_id
388 )
389 elif ip.assigned_object and self.__class__ == NBVirtualMachine:
390 self.interfaces_by_ip[
391 ip.address
392 ] = netboxapi.virtualization.interfaces.get(ip.assigned_object_id)
393 self.interfaces_by_ip[ip.address].mgmt_only = False
394
395 logger.debug(
396 "%s id: %d, data: %s, ips: %s"
397 % (self.type, self.id, dict(self.data), self.ips)
398 )
399
400 self.netplan_config = dict()
401
402 def __repr__(self):
403 return str(dict(self.data))
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700404
405 def dns_name(self, ip, prefix):
406 """
407 Returns the DNS name for the device at this IP in the prefix
408 """
409
410 def first_segment_suffix(split_name, suffixes, segments):
411 first_seg = "-".join([split_name[0], *suffixes])
412
413 if segments > 1:
414 name = ".".join([first_seg, *split_name[1:segments]])
415 else:
416 name = first_seg
417
418 return name
419
420 # clean/split the device name
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800421 name_split = clean_name_dns(self.data.name).split(".")
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700422
423 # always add interface suffix to mgmt interfaces
424 if self.interfaces_by_ip[ip].mgmt_only:
425 return first_segment_suffix(
426 name_split, [self.interfaces_by_ip[ip].name], prefix.name_segments
427 )
428
429 # find all IP's for this device in the prefix that aren't mgmt interfaces
430 prefix_ips = []
431 for s_ip in self.ips:
432 if s_ip in prefix.aos and not self.interfaces_by_ip[s_ip].mgmt_only:
433 prefix_ips.append(s_ip)
434
435 # name to use when only one IP address for device in a prefix
436 simple_name = ".".join(name_split[0 : prefix.name_segments])
437
438 # if more than one non-mgmt IP in prefix
439 if len(prefix_ips) > 1:
440
441 # use bare name if primary IP address
442 try: # skip if no primary_ip.address
443 if ip == self.data.primary_ip.address:
444 return simple_name
445 except AttributeError:
446 pass
447
448 # else, suffix with the interface name, and the last octet of IP address
449 return first_segment_suffix(
450 name_split,
451 [
452 self.interfaces_by_ip[ip].name,
453 str(netaddr.IPNetwork(ip).ip.words[3]),
454 ],
455 prefix.name_segments,
456 )
457
458 # simplest case - only one IP in prefix, return simple_name
459 return simple_name
460
461 def dns_cnames(self, ip):
462 """
463 returns a list of cnames for this object, based on IP matches
464 """
465
466 cnames = []
467
468 for service in self.services:
469
470 # if not assigned to any IP's, service is on all IPs
471 if not service.ipaddresses:
472 cnames.append(service.name)
473 continue
474
475 # If assigned to an IP, only create a CNAME on that IP
476 for service_ip in service.ipaddresses:
477 if ip == service_ip.address:
478 cnames.append(service.name)
479
480 return cnames
481
482 def has_service(self, cidr_ip, port, protocol):
483 """
484 Return True if this AO has a service using specific port and protocol combination
485 """
486
487 if (
488 cidr_ip in self.interfaces_by_ip
489 and not self.interfaces_by_ip[cidr_ip].mgmt_only
490 ):
491 for service in self.services:
492 if service.port == port and service.protocol.value == protocol:
493 return True
494
495 return False
496
497 def primary_iface(self):
498 """
499 Returns the interface data for the device that has the primary_ip
500 """
501
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800502 if self.data.primary_ip:
503 return self.interfaces_by_ip[self.data.primary_ip.address]
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700504
505 return None
506
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800507 @property
508 def type(self):
509 return "AssignedObject"
510
511 @classmethod
512 def get_by_id(cls, obj_id):
513 raise Exception("not implemented")
514
515 @classmethod
516 def all_objects(cls):
517 return cls.objects
518
519 @classmethod
520 def to_yaml(cls, representer, node):
521 return representer.represent_dict(
522 {
523 "data": node.data,
524 "services": node.services,
525 "ips": node.ips,
526 "interfaces_by_ip": node.interfaces_by_ip,
527 }
528 )
529
530 def generate_netplan(self):
531 """
532 Get the interface config of specific server belongs to this tenant
533 """
534
535 if self.netplan_config:
536 return self.netplan_config
537
538 if not self.data:
539 logger.error(
540 "{type} {name} doesn't have data yet.".format(
541 type=self.type, name=self.name
542 )
543 )
544 sys.exit(1)
545
546 primary_ip = self.data.primary_ip.address if self.data.primary_ip else None
547 primary_if = self.interfaces_by_ip[primary_ip] if primary_ip else None
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800548
549 self.netplan_config["ethernets"] = dict()
550
551 if self.data.device_role.name == "Router":
552 for address, interface in self.interfaces_by_ip.items():
Wei-Yu Chenb43fc322021-07-21 14:50:16 +0800553 if interface.mgmt_only is True or str(interface.type) == "Virtual":
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800554 continue
555
556 self.netplan_config["ethernets"].setdefault(interface.name, {})
557 self.netplan_config["ethernets"][interface.name].setdefault(
558 "addresses", []
559 ).append(address)
560
561 elif self.data.device_role.name == "Server":
562 if primary_if:
563 self.netplan_config["ethernets"][primary_if.name] = {
564 "dhcp4": "yes",
565 "dhcp4-overrides": {"route-metric": 100},
566 }
567
Wei-Yu Chenb43fc322021-07-21 14:50:16 +0800568 for physical_if in filter(
569 lambda i: str(i.type) != "Virtual"
570 and i != primary_if
571 and i.mgmt_only is False,
572 self.interfaces,
573 ):
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800574 self.netplan_config["ethernets"][physical_if.name] = {
575 "dhcp4": "yes",
576 "dhcp4-overrides": {"route-metric": 200},
577 }
578 else:
579 # Exclude the device type which is not Router and Server
580 return None
581
582 # Get interfaces own by AssignedObject and is virtual (VLAN interface)
Wei-Yu Chenb43fc322021-07-21 14:50:16 +0800583 for virtual_if in filter(lambda i: str(i.type) == "Virtual", self.interfaces):
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800584 if "vlans" not in self.netplan_config:
585 self.netplan_config["vlans"] = dict()
586
587 # vlan_object_id is the "id" on netbox, it's different from known VLAN ID
588 vlan_object_id = virtual_if.tagged_vlans[0].id
589 vlan_object = netboxapi.ipam.vlans.get(vlan_object_id)
590 virtual_if_ips = netboxapi.ipam.ip_addresses.filter(
591 interface_id=virtual_if.id
592 )
593
Wei-Yu Chen6ca7bc12021-07-19 19:53:43 +0800594 routes = []
595 for ip in virtual_if_ips:
596 reserved_ips = NBPrefix.all_reserved_by_ip(str(ip))
597 for reserved_ip in reserved_ips:
598 destination = reserved_ip["custom_fields"].get("rfc3442routes", "")
599 if destination:
600 for dest_ip in destination.split():
601 new_route = {
602 "to": dest_ip,
603 "via": str(netaddr.IPNetwork(reserved_ip["ip4"]).ip),
604 "metric": 100,
605 }
606 if new_route not in routes:
607 routes.append(new_route)
608
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800609 self.netplan_config["vlans"][virtual_if.name] = {
610 "id": vlan_object.vid,
611 "link": virtual_if.label,
612 "addresses": [ip.address for ip in virtual_if_ips],
613 }
614
Wei-Yu Chen6ca7bc12021-07-19 19:53:43 +0800615 if routes:
616 self.netplan_config["vlans"][virtual_if.name]["routes"] = routes
617
Wei-Yu Chen8ade3302021-07-19 20:53:47 +0800618 # If the object is mgmtserver, it needs to have DNS/NTP server configs
619 if self.data["device_role"]["name"] == "Router":
620 services = list(netboxapi.ipam.services.filter(device_id=self.id))
621 service_names = list(map(lambda x: x.name, services))
622
623 if "dns" in service_names:
624 unbound_listen_ips = []
625 unbound_allow_ips = []
626
627 for ip, intf in self.interfaces_by_ip.items():
628 if ip != primary_ip and intf.mgmt_only == False:
629 unbound_listen_ips.append(ip)
630
631 for prefix in NBPrefix.all_prefixes().values():
632 if prefix.data.description:
633 unbound_allow_ips.append(prefix.data.prefix)
634 ntp_client_allow.append(prefix.data.prefix)
635
636 if unbound_listen_ips:
637 self.netplan_config["unbound_listen_ips"] = unbound_listen_ips
638
639 if unbound_allow_ips:
640 self.netplan_config["unbound_allow_ips"] = unbound_allow_ips
641
642 if "ntp" in service_names:
643 ntp_client_allow = []
644
645 for prefix in NBPrefix.all_prefixes().values():
646 if prefix.data.description:
647 ntp_client_allow.append(prefix.data.prefix)
648
649 if ntp_client_allow:
650 self.netplan_config["ntp_client_allow"] = ntp_client_allow
651
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800652 return self.netplan_config
653
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700654
655@yaml.yaml_object(ydump)
656class NBDevice(NBAssignedObject):
657 """
658 Wraps a single Netbox device
659 Also caches all known devices in a class variable (devs)
660 """
661
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800662 objects = dict()
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700663
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800664 def __init__(self, data):
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700665
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800666 super().__init__(data)
667 self.objects[self.id] = self
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700668
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800669 @property
670 def type(self):
671 return "NBDevice"
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700672
673 def get_interfaces(self):
674 if not self.interfaces:
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800675 self.interfaces = netboxapi.dcim.interfaces.filter(device_id=self.id)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700676
677 return self.interfaces
678
679 @classmethod
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800680 def get_by_id(cls, obj_id):
681 obj = cls.objects.get(obj_id, None)
682 obj = obj or NBDevice(netboxapi.dcim.devices.get(obj_id))
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700683
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800684 return obj
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700685
686
687@yaml.yaml_object(ydump)
688class NBVirtualMachine(NBAssignedObject):
689 """
690 VM equivalent of NBDevice
691 """
692
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800693 objects = dict()
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700694
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800695 def __init__(self, data):
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700696
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800697 super().__init__(data)
698 self.objects[self.id] = self
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700699
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800700 @property
701 def type(self):
702 return "NBVirtualMachine"
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700703
704 def get_interfaces(self):
705 if not self.interfaces:
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800706 self.interfaces = netboxapi.virtualization.interfaces.filter(
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700707 virtual_machine_id=self.id
708 )
709
710 return self.interfaces
711
712 @classmethod
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800713 def get_by_id(cls, obj_id):
714 obj = cls.objects.get(obj_id, None)
715 obj = obj or NBVirtualMachine(
716 netboxapi.virtualization.virtual_machines.get(obj_id)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700717 )
718
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800719 return obj
720
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700721
722@yaml.yaml_object(ydump)
723class NBDNSForwardZone:
724
725 fwd_zones = {}
726
727 def __init__(self, prefix):
728
729 self.domain_extension = prefix.domain_extension
730
731 self.a_recs = {}
732 self.cname_recs = {}
733 self.srv_recs = {}
734 self.ns_recs = []
735 self.txt_recs = {}
736
737 if prefix.dhcp_range:
738 self.create_dhcp_fwd(prefix.dhcp_range)
739
740 for ip, ao in prefix.aos.items():
741 self.add_ao_records(prefix, ip, ao)
742
743 for ip, res in prefix.reserved_ips.items():
744 self.add_reserved(ip, res)
745
746 # reqquired for the add_fwd_cname function below
747 if callable(getattr(prefix, "parent")):
748 parent_prefix = prefix.parent()
749
750 if parent_prefix:
751 self.merge_parent_prefix(parent_prefix, prefix)
752
753 self.fwd_zones[self.domain_extension] = self
754
755 def __repr__(self):
756 return str(
757 {
758 "a": self.a_recs,
759 "cname": self.cname_recs,
760 "ns": self.ns_recs,
761 "srv": self.srv_recs,
762 "txt": self.txt_recs,
763 }
764 )
765
766 @classmethod
767 def add_fwd_cname(cls, cname, fqdn_dest):
768 """
769 Add an arbitrary CNAME (and possibly create the fwd zone if needed) pointing
770 at a FQDN destination name. It's used to support the per-IP "DNS name" field in NetBox
771 Note that the NS record
772 """
773
774 try:
775 fqdn_split = re.compile(r"([a-z]+)\.([a-z.]+)\.")
776 (short_name, extension) = fqdn_split.match(cname).groups()
777
778 except AttributeError:
779 logger.warning(
780 "Invalid DNS CNAME: '%s', must be in FQDN format: 'host.example.com.', ignored",
781 cname,
782 )
783 return
784
785 fake_prefix = AttrDict(
786 {
787 "domain_extension": extension,
788 "dhcp_range": None,
789 "aos": {},
790 "reserved_ips": {},
791 "parent": None,
792 }
793 )
794
795 fwd_zone = cls.get_fwd_zone(fake_prefix)
796
797 fwd_zone.cname_recs[short_name] = fqdn_dest
798
799 @classmethod
800 def get_fwd_zone(cls, prefix):
801 if prefix.domain_extension in cls.fwd_zones:
802 return cls.fwd_zones[prefix.domain_extension]
803
804 return NBDNSForwardZone(prefix)
805
806 @classmethod
807 def all_fwd_zones(cls):
808 return cls.fwd_zones
809
810 @classmethod
811 def to_yaml(cls, representer, node):
812 return representer.represent_dict(
813 {
814 "a": node.a_recs,
815 "cname": node.cname_recs,
816 "ns": node.ns_recs,
817 "srv": node.srv_recs,
818 "txt": node.txt_recs,
819 }
820 )
821
822 def fqdn(self, name):
823 return "%s.%s." % (name, self.domain_extension)
824
825 def create_dhcp_fwd(self, dhcp_range):
826
827 for ip in netaddr.IPNetwork(dhcp_range).iter_hosts():
828 self.a_recs["dhcp%03d" % (ip.words[3])] = str(ip)
829
830 def name_is_duplicate(self, name, target, record_type):
831 """
832 Returns True if name already exists in the zone as an A or CNAME
833 record, False otherwise
834 """
835
836 if name in self.a_recs:
837 logger.warning(
838 "Duplicate DNS record for name %s - A record to '%s', %s record to '%s'",
839 name,
840 self.a_recs[name],
841 record_type,
842 target,
843 )
844 return True
845
846 if name in self.cname_recs:
847 logger.warning(
848 "Duplicate DNS record for name %s - CNAME record to '%s', %s record to '%s'",
849 name,
850 self.cname_recs[name],
851 record_type,
852 target,
853 )
854 return True
855
856 return False
857
858 def add_ao_records(self, prefix, ip, ao):
859
860 name = ao.dns_name(ip, prefix)
861 target_ip = str(netaddr.IPNetwork(ip).ip) # make bare IP, not CIDR format
862
863 # add A records
864 if not self.name_is_duplicate(name, target_ip, "A"):
865 self.a_recs[name] = target_ip
866
867 # add CNAME records that alias to this name
868 for cname in ao.dns_cnames(ip):
869 # check that it isn't a dupe
870 if not self.name_is_duplicate(cname, target_ip, "CNAME"):
871 self.cname_recs[cname] = self.fqdn(name)
872
873 # add NS records if this is a DNS server
874 if ao.has_service(ip, 53, "udp"):
875 self.ns_recs.append(self.fqdn(name))
876
877 # if a DNS name is set, add it as a CNAME
878 if ao.ips[ip]["dns_name"]: # and ip == aos.data.primary_ip.address:
879 self.add_fwd_cname(ao.ips[ip]["dns_name"], self.fqdn(name))
880
881 def add_reserved(self, ip, res):
882
883 target_ip = str(netaddr.IPNetwork(ip).ip) # make bare IP, not CIDR format
884
885 if not self.name_is_duplicate(res["name"], target_ip, "A"):
886 self.a_recs[res["name"]] = target_ip
887
888 def merge_parent_prefix(self, pprefix, prefix):
889
890 # only if no NS records exist already
891 if not self.ns_recs:
892 # scan parent prefix for services
893 for ip, ao in pprefix.aos.items():
894
895 # Create a DNS within this prefix pointing to out-of-prefix IP
896 # where DNS server is
897 name = ao.dns_name(ip, prefix)
898 target_ip = str(
899 netaddr.IPNetwork(ip).ip
900 ) # make bare IP, not CIDR format
901
902 # add NS records if this is a DNS server
903 if ao.has_service(ip, 53, "udp"):
904 self.a_recs[name] = target_ip
905 self.ns_recs.append(self.fqdn(name))
906
907
908@yaml.yaml_object(ydump)
909class NBDNSReverseZones:
910 def __init__(self):
911
912 self.reverse_zones = {}
913
914 @classmethod
915 def to_yaml(cls, representer, node):
916 return representer.represent_dict(node.reverse_zones)
917
918 @classmethod
919 def canonicalize_rfc1918_prefix(cls, prefix):
920 """
921 RFC1918 prefixes need to be expanded to their widest canonical range to
922 group all reverse lookup domains together for reverse DNS with NSD/Unbound.
923 """
924
925 pnet = netaddr.IPNetwork(str(prefix))
926 (o1, o2, o3, o4) = pnet.network.words # Split ipv4 octets
927 cidr_plen = pnet.prefixlen
928
929 if o1 == 10:
930 o2 = o3 = o4 = 0
931 cidr_plen = 8
932 elif (o1 == 172 and o2 >= 16 and o2 <= 31) or (o1 == 192 and o2 == 168):
933 o3 = o4 = 0
934 cidr_plen = 16
935
936 return "%s/%d" % (".".join(map(str, [o1, o2, o3, o4])), cidr_plen)
937
938 def add_prefix(self, prefix):
939
940 canonical_prefix = self.canonicalize_rfc1918_prefix(prefix)
941
942 if canonical_prefix in self.reverse_zones:
943 rzone = self.reverse_zones[canonical_prefix]
944 else:
945 rzone = {
946 "ns": [],
947 "ptr": {},
948 }
949
950 if prefix.dhcp_range:
951 # FIXME: doesn't check for duplicate entries
952 rzone["ptr"].update(self.create_dhcp_rev(prefix))
953
954 for ip, ao in prefix.aos.items():
955 target_ip = str(netaddr.IPNetwork(ip).ip) # make bare IP, not CIDR format
956 ao_name = self.get_ao_name(ip, ao, prefix,)
957 rzone["ptr"][target_ip] = ao_name
958
959 # add NS records if this is a DNS server
960 if ao.has_service(ip, 53, "udp"):
961 rzone["ns"].append(ao_name)
962
963 parent_prefix = prefix.parent()
964
965 if parent_prefix:
966 self.merge_parent_prefix(rzone, parent_prefix)
967
968 self.reverse_zones[canonical_prefix] = rzone
969
970 def merge_parent_prefix(self, rzone, pprefix):
971
972 # parent items
973 p_ns = []
974
975 # scan parent prefix for services
976 for ip, ao in pprefix.aos.items():
977
978 ao_name = self.get_ao_name(ip, ao, pprefix,)
979
980 # add NS records if this is a DNS server
981 if ao.has_service(ip, 53, "udp"):
982 p_ns.append(ao_name)
983
984 # set DNS servers if none in rzone
985 if not rzone["ns"]:
986 rzone["ns"] = p_ns
987
988 def create_dhcp_rev(self, prefix):
989
990 dhcp_rzone = {}
991
992 for ip in netaddr.IPNetwork(prefix.dhcp_range).iter_hosts():
993 dhcp_rzone[str(ip)] = "dhcp%03d.%s." % (
994 ip.words[3],
995 prefix.domain_extension,
996 )
997
998 return dhcp_rzone
999
1000 def get_ao_name(self, ip, ao, prefix):
1001 short_name = ao.dns_name(ip, prefix)
1002 return "%s.%s." % (short_name, prefix.domain_extension)
1003
1004
1005@yaml.yaml_object(ydump)
1006class NBDHCPSubnet:
1007 def __init__(self, prefix):
1008
1009 self.domain_extension = prefix.domain_extension
1010
1011 self.subnet = None
1012 self.range = None
1013 self.first_ip = None
1014 self.hosts = []
1015 self.routers = []
1016 self.dns_servers = []
1017 self.dns_search = []
1018 self.tftpd_server = None
1019 self.ntp_servers = []
1020 self.dhcpd_interface = None
1021
1022 self.add_prefix(prefix)
1023
1024 for ip, ao in prefix.aos.items():
1025 self.add_ao(str(ip), ao, prefix)
1026
1027 parent_prefix = prefix.parent()
1028
1029 if parent_prefix:
1030 self.merge_parent_prefix(parent_prefix)
1031
1032 def add_prefix(self, prefix):
1033
1034 self.subnet = str(prefix)
1035
1036 self.first_ip = str(netaddr.IPAddress(netaddr.IPNetwork(str(prefix)).first + 1))
1037
1038 self.dns_search = [prefix.domain_extension]
1039
1040 if prefix.dhcp_range:
1041 self.range = prefix.dhcp_range
1042
1043 for ip, res in prefix.reserved_ips.items():
1044 # routers are reserved IP's that start with 'router" in the IP description
1045 if re.match("router", res["description"]):
1046 router = {"ip": str(netaddr.IPNetwork(ip).ip)}
1047
1048 if (
1049 "rfc3442routes" in res["custom_fields"]
1050 and res["custom_fields"]["rfc3442routes"]
1051 ):
1052 # split on whitespace
1053 router["rfc3442routes"] = re.split(
1054 r"\s+", res["custom_fields"]["rfc3442routes"]
1055 )
1056
1057 self.routers.append(router)
1058
1059 # set first IP to router if not set otherwise.
1060 if not self.routers:
1061 router = {"ip": self.first_ip}
1062
1063 self.routers.append(router)
1064
1065 def add_ao(self, ip, ao, prefix):
1066
1067 target_ip = str(netaddr.IPNetwork(ip).ip) # make bare IP, not CIDR format
1068
1069 # find the DHCP interface if it's this IP
1070 if target_ip == self.first_ip:
1071 self.dhcpd_interface = ao.interfaces_by_ip[ip].name
1072
1073 name = ao.dns_name(ip, prefix)
1074
1075 # add only devices that have a macaddr for this IP
1076 if ip in ao.interfaces_by_ip:
1077
1078 mac_addr = dict(ao.interfaces_by_ip[ip]).get("mac_address")
1079
1080 if mac_addr and mac_addr.strip(): # if exists and not blank
1081 self.hosts.append(
Wei-Yu Chenb43fc322021-07-21 14:50:16 +08001082 {"name": name, "ip_addr": target_ip, "mac_addr": mac_addr.lower()}
Zack Williams2aeb3ef2021-06-11 17:10:36 -07001083 )
1084
1085 # add dns servers
1086 if ao.has_service(ip, 53, "udp"):
1087 self.dns_servers.append(target_ip)
1088
1089 # add tftp server
1090 if ao.has_service(ip, 69, "udp"):
1091 if not self.tftpd_server:
1092 self.tftpd_server = target_ip
1093 else:
1094 logger.warning(
1095 "Duplicate TFTP servers in prefix, using first of %s and %s",
1096 self.tftpd_server,
1097 target_ip,
1098 )
1099
1100 # add NTP servers
1101 if ao.has_service(ip, 123, "udp"):
1102 self.ntp_servers.append(target_ip)
1103
1104 def merge_parent_prefix(self, pprefix):
1105
1106 # parent items
1107 p_dns_servers = []
1108 p_tftpd_server = None
1109 p_ntp_servers = []
1110
1111 # scan parent prefix for services
1112 for ip, ao in pprefix.aos.items():
1113
1114 target_ip = str(netaddr.IPNetwork(ip).ip)
1115
1116 # add dns servers
1117 if ao.has_service(ip, 53, "udp"):
1118 p_dns_servers.append(target_ip)
1119
1120 # add tftp server
1121 if ao.has_service(ip, 69, "udp"):
1122 if not p_tftpd_server:
1123 p_tftpd_server = target_ip
1124 else:
1125 logger.warning(
1126 "Duplicate TFTP servers in parent prefix, using first of %s and %s",
1127 p_tftpd_server,
1128 target_ip,
1129 )
1130
1131 # add NTP servers
1132 if ao.has_service(ip, 123, "udp"):
1133 p_ntp_servers.append(target_ip)
1134
1135 # merge if doesn't exist in prefix
1136 if not self.dns_servers:
1137 self.dns_servers = p_dns_servers
1138
1139 if not self.tftpd_server:
1140 self.tftpd_server = p_tftpd_server
1141
1142 if not self.ntp_servers:
1143 self.ntp_servers = p_ntp_servers
1144
1145 @classmethod
1146 def to_yaml(cls, representer, node):
1147 return representer.represent_dict(
1148 {
1149 "subnet": node.subnet,
1150 "range": node.range,
1151 "routers": node.routers,
1152 "hosts": node.hosts,
1153 "dns_servers": node.dns_servers,
1154 "dns_search": node.dns_search,
1155 "tftpd_server": node.tftpd_server,
1156 "ntp_servers": node.ntp_servers,
1157 }
1158 )