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