blob: ecdfc663701cfc0e84462967f56da70d1b186475 [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
Wei-Yu Chenc517f552021-07-29 21:16:45 +0800168 def get_device_by_name(self, name):
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700169 """
Wei-Yu Chenc517f552021-07-29 21:16:45 +0800170 Find the device or VM which belongs to this Tenant,
171 If the name wasn't specified, return the management server
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700172 """
173
Wei-Yu Chenc517f552021-07-29 21:16:45 +0800174 for machine in self.devices + self.vms:
175 if name and machine.name == name:
176 return machine
177 elif machine.data["device_role"]["name"] == "Router":
178 return machine
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700179
Wei-Yu Chenc517f552021-07-29 21:16:45 +0800180 ret_msg = (
181 "The name '%s' wasn't found in this tenant, "
182 + "or can't found any Router in this tenant"
183 )
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700184
Wei-Yu Chenc517f552021-07-29 21:16:45 +0800185 logger.error(ret_msg, name)
186 sys.exit(1)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700187
Wei-Yu Chenc517f552021-07-29 21:16:45 +0800188 def get_devices(self, device_types=["server", "router"]):
Zack Williams5d66d182021-07-27 23:08:28 -0700189 """
Wei-Yu Chenc517f552021-07-29 21:16:45 +0800190 Get all devices (Router + Server) belong to this Tenant
Zack Williams5d66d182021-07-27 23:08:28 -0700191 """
192
Wei-Yu Chenc517f552021-07-29 21:16:45 +0800193 if not device_types:
194 return self.devices + self.vms
Zack Williams5d66d182021-07-27 23:08:28 -0700195
Wei-Yu Chenc517f552021-07-29 21:16:45 +0800196 ret = []
Zack Williams5d66d182021-07-27 23:08:28 -0700197
Wei-Yu Chenc517f552021-07-29 21:16:45 +0800198 for machine in self.devices:
199 if machine.data.device_role.slug in device_types:
200 ret.append(machine)
201
202 for vm in self.vms:
203 if vm.data.role.slug in device_types:
204 ret.append(vm)
205
206 return ret
Zack Williams5d66d182021-07-27 23:08:28 -0700207
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700208
209@yaml.yaml_object(ydump)
210class NBPrefix:
211
212 prefixes = {}
213
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800214 def __init__(self, data, name_segments):
215 self.data = data
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700216 self.name_segments = name_segments
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800217 self.domain_extension = check_name_dns(self.data.description)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700218
219 logger.debug(
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800220 "Preix %s: domain_extension %s, data: %s",
221 self.data.prefix,
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700222 self.domain_extension,
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800223 dict(self.data),
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700224 )
225
226 # ip centric info
227 self.dhcp_range = None
228 self.reserved_ips = {}
229 self.aos = {}
230
231 # build item lists
232 self.build_prefix()
Wei-Yu Chen8ade3302021-07-19 20:53:47 +0800233 self.prefixes[self.data.prefix] = self
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700234
235 @classmethod
236 def all_prefixes(cls):
237 return cls.prefixes
238
239 @classmethod
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800240 def get_prefix(cls, prefix, name_segments=1):
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700241 if prefix in cls.prefixes:
242 return cls.prefixes[prefix]
243
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800244 data = netboxapi.ipam.prefixes.get(prefix=prefix)
245 if data:
246 return NBPrefix(data, name_segments)
247 else:
248 raise Exception("The prefix %s wasn't found in Netbox" % prefix)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700249
250 def __repr__(self):
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800251 return str(self.data.prefix)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700252
253 @classmethod
254 def to_yaml(cls, representer, node):
255 return representer.represent_dict(
256 {
257 "dhcp_range": node.dhcp_range,
258 "reserved_ips": node.reserved_ips,
259 "aos": node.aos,
260 "prefix_data": dict(node.prefix_data),
261 }
262 )
263
Wei-Yu Chen6ca7bc12021-07-19 19:53:43 +0800264 @classmethod
265 def all_reserved_by_ip(cls, ip_addr=""):
266 """
267 all_reserved_by_ip will return all reserved IP found in prefixes
268
269 We have the IP address marked as type 'Reserved' in Prefix,
270 This type of IP address is using to define a DHCP range
271 """
272
273 ret = list()
274
275 for prefix in cls.prefixes.values():
276 if ip_addr and ip_addr in prefix.aos.keys():
277 if prefix.reserved_ips:
278 return list(prefix.reserved_ips.values())
279 else:
280 if prefix.reserved_ips:
281 ret.extend(list(prefix.reserved_ips.values()))
282
283 return ret
284
285 def get_reserved_ips(self):
286 """
287 Get the reserved IP range (DHCP) in prefix
288
289 We have the IP address marked as type 'Reserved' in Prefix,
290 This type of IP address is using to define a DHCP range
291 """
292 if prefix.reserved_ips:
293 return list(prefix.reserved_ips.values())
294
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700295 def parent(self):
296 """
297 Get the parent prefix to this prefix
298
299 FIXME: Doesn't handle multiple layers of prefixes, returns first found
300 """
301
302 # get all parents of this prefix (include self)
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800303 possible_parents = netboxapi.ipam.prefixes.filter(contains=self.data.prefix)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700304
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800305 logger.debug(
306 "Prefix %s: possible parents %s", self.data.prefix, possible_parents
307 )
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700308
309 # filter out self, return first found
310 for pparent in possible_parents:
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800311 if pparent.prefix != self.data.prefix:
312 return NBPrefix.get_prefix(pparent.prefix, self.name_segments)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700313
314 return None
315
316 def build_prefix(self):
317 """
318 find ip information for items (devices/vms, reserved_ips, dhcp_range) in prefix
319 """
320
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800321 ips = netboxapi.ipam.ip_addresses.filter(parent=self.data.prefix)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700322
323 for ip in sorted(ips, key=lambda k: k["address"]):
324
325 logger.debug("prefix_item ip: %s, data: %s", ip, dict(ip))
326
327 # if it's a DHCP range, add that range to the dev list as prefix_dhcp
328 if ip.status.value == "dhcp":
329 self.dhcp_range = str(ip.address)
330 continue
331
332 # reserved IPs
333 if ip.status.value == "reserved":
334
335 res = {}
336 res["name"] = ip.description.lower().split(" ")[0]
337 res["description"] = ip.description
338 res["ip4"] = str(netaddr.IPNetwork(ip.address))
339 res["custom_fields"] = ip.custom_fields
340
341 self.reserved_ips[str(ip)] = res
342 continue
343
344 # devices and VMs
345 if ip.assigned_object: # can be null if not assigned to a device/vm
346 aotype = ip.assigned_object_type
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700347 if aotype == "dcim.interface":
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800348 self.aos[str(ip)] = NBDevice.get_by_id(
349 ip.assigned_object.device.id,
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700350 )
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700351 elif aotype == "virtualization.vminterface":
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800352 self.aos[str(ip)] = NBVirtualMachine.get_by_id(
353 ip.assigned_object.virtual_machine.id,
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700354 )
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700355 else:
356 logger.error("IP %s has unknown device type: %s", ip, aotype)
357 sys.exit(1)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700358 else:
359 logger.warning("Unknown IP type %s, with attributes: %s", ip, dict(ip))
360
361
362@yaml.yaml_object(ydump)
363class NBAssignedObject:
364 """
365 Assigned Object is either a Device or Virtual Machine, which function
366 nearly identically in the NetBox data model.
367
368 This parent class holds common functions for those two child classes
369 """
370
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800371 objects = dict()
372
373 def __init__(self, data):
374 self.data = data
375
376 # The AssignedObject attributes
377 self.id = data.id
378 self.name = data.name
379 self.ips = dict()
380
381 # The NetBox objects related with this AssignedObject
382 self.services = None
383 self.interfaces = list()
384 self.mgmt_interfaces = list()
385 self.interfaces_by_ip = dict()
386
387 if self.__class__ == NBDevice:
388 self.interfaces = netboxapi.dcim.interfaces.filter(device_id=self.id)
389 self.services = netboxapi.ipam.services.filter(device_id=self.id)
390 ip_addresses = netboxapi.ipam.ip_addresses.filter(device_id=self.id)
391 elif self.__class__ == NBVirtualMachine:
392 self.interfaces = netboxapi.virtualization.interfaces.filter(
393 virtual_machine_id=self.id
394 )
395 self.services = netboxapi.ipam.services.filter(virtual_machine_id=self.id)
396 ip_addresses = netboxapi.ipam.ip_addresses.filter(
397 virtual_machine_id=self.id
398 )
399
400 for ip in ip_addresses:
401 self.ips[ip.address] = ip
402 if ip.assigned_object and self.__class__ == NBDevice:
403 self.interfaces_by_ip[ip.address] = netboxapi.dcim.interfaces.get(
404 ip.assigned_object_id
405 )
406 elif ip.assigned_object and self.__class__ == NBVirtualMachine:
407 self.interfaces_by_ip[
408 ip.address
409 ] = netboxapi.virtualization.interfaces.get(ip.assigned_object_id)
410 self.interfaces_by_ip[ip.address].mgmt_only = False
411
412 logger.debug(
413 "%s id: %d, data: %s, ips: %s"
414 % (self.type, self.id, dict(self.data), self.ips)
415 )
416
417 self.netplan_config = dict()
Zack Williams5d66d182021-07-27 23:08:28 -0700418 self.extra_config = dict()
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800419
420 def __repr__(self):
421 return str(dict(self.data))
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700422
423 def dns_name(self, ip, prefix):
424 """
425 Returns the DNS name for the device at this IP in the prefix
426 """
427
428 def first_segment_suffix(split_name, suffixes, segments):
429 first_seg = "-".join([split_name[0], *suffixes])
430
431 if segments > 1:
432 name = ".".join([first_seg, *split_name[1:segments]])
433 else:
434 name = first_seg
435
436 return name
437
438 # clean/split the device name
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800439 name_split = clean_name_dns(self.data.name).split(".")
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700440
441 # always add interface suffix to mgmt interfaces
442 if self.interfaces_by_ip[ip].mgmt_only:
443 return first_segment_suffix(
444 name_split, [self.interfaces_by_ip[ip].name], prefix.name_segments
445 )
446
447 # find all IP's for this device in the prefix that aren't mgmt interfaces
448 prefix_ips = []
449 for s_ip in self.ips:
450 if s_ip in prefix.aos and not self.interfaces_by_ip[s_ip].mgmt_only:
451 prefix_ips.append(s_ip)
452
453 # name to use when only one IP address for device in a prefix
454 simple_name = ".".join(name_split[0 : prefix.name_segments])
455
456 # if more than one non-mgmt IP in prefix
457 if len(prefix_ips) > 1:
458
459 # use bare name if primary IP address
460 try: # skip if no primary_ip.address
461 if ip == self.data.primary_ip.address:
462 return simple_name
463 except AttributeError:
464 pass
465
466 # else, suffix with the interface name, and the last octet of IP address
467 return first_segment_suffix(
468 name_split,
469 [
470 self.interfaces_by_ip[ip].name,
471 str(netaddr.IPNetwork(ip).ip.words[3]),
472 ],
473 prefix.name_segments,
474 )
475
476 # simplest case - only one IP in prefix, return simple_name
477 return simple_name
478
479 def dns_cnames(self, ip):
480 """
481 returns a list of cnames for this object, based on IP matches
482 """
483
484 cnames = []
485
486 for service in self.services:
487
488 # if not assigned to any IP's, service is on all IPs
489 if not service.ipaddresses:
490 cnames.append(service.name)
491 continue
492
493 # If assigned to an IP, only create a CNAME on that IP
494 for service_ip in service.ipaddresses:
495 if ip == service_ip.address:
496 cnames.append(service.name)
497
498 return cnames
499
500 def has_service(self, cidr_ip, port, protocol):
501 """
502 Return True if this AO has a service using specific port and protocol combination
503 """
504
505 if (
506 cidr_ip in self.interfaces_by_ip
507 and not self.interfaces_by_ip[cidr_ip].mgmt_only
508 ):
509 for service in self.services:
510 if service.port == port and service.protocol.value == protocol:
511 return True
512
513 return False
514
515 def primary_iface(self):
516 """
517 Returns the interface data for the device that has the primary_ip
518 """
519
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800520 if self.data.primary_ip:
521 return self.interfaces_by_ip[self.data.primary_ip.address]
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700522
523 return None
524
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800525 @property
526 def type(self):
527 return "AssignedObject"
528
529 @classmethod
530 def get_by_id(cls, obj_id):
531 raise Exception("not implemented")
532
533 @classmethod
534 def all_objects(cls):
535 return cls.objects
536
537 @classmethod
538 def to_yaml(cls, representer, node):
539 return representer.represent_dict(
540 {
541 "data": node.data,
542 "services": node.services,
543 "ips": node.ips,
544 "interfaces_by_ip": node.interfaces_by_ip,
545 }
546 )
547
548 def generate_netplan(self):
549 """
550 Get the interface config of specific server belongs to this tenant
551 """
552
553 if self.netplan_config:
554 return self.netplan_config
555
556 if not self.data:
557 logger.error(
558 "{type} {name} doesn't have data yet.".format(
559 type=self.type, name=self.name
560 )
561 )
562 sys.exit(1)
563
564 primary_ip = self.data.primary_ip.address if self.data.primary_ip else None
565 primary_if = self.interfaces_by_ip[primary_ip] if primary_ip else None
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800566
567 self.netplan_config["ethernets"] = dict()
568
Wei-Yu Chenc517f552021-07-29 21:16:45 +0800569 if (isinstance(self, NBDevice) and self.data.device_role.name == "Router") or (
570 isinstance(self, NBVirtualMachine) and self.data.role.name == "Router"
571 ):
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800572 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
Wei-Yu Chenc517f552021-07-29 21:16:45 +0800581 elif isinstance(self, NBDevice) and self.data.device_role.name == "Server":
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800582 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
Wei-Yu Chenc517f552021-07-29 21:16:45 +0800607 if not virtual_if.tagged_vlans:
608 # If a virtual interface doesn't have tagged VLAN, skip
609 continue
610
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800611 # vlan_object_id is the "id" on netbox, it's different from known VLAN ID
612 vlan_object_id = virtual_if.tagged_vlans[0].id
613 vlan_object = netboxapi.ipam.vlans.get(vlan_object_id)
614 virtual_if_ips = netboxapi.ipam.ip_addresses.filter(
615 interface_id=virtual_if.id
616 )
617
Wei-Yu Chen6ca7bc12021-07-19 19:53:43 +0800618 routes = []
619 for ip in virtual_if_ips:
620 reserved_ips = NBPrefix.all_reserved_by_ip(str(ip))
621 for reserved_ip in reserved_ips:
622 destination = reserved_ip["custom_fields"].get("rfc3442routes", "")
623 if destination:
624 for dest_ip in destination.split():
625 new_route = {
626 "to": dest_ip,
627 "via": str(netaddr.IPNetwork(reserved_ip["ip4"]).ip),
628 "metric": 100,
629 }
630 if new_route not in routes:
631 routes.append(new_route)
632
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800633 self.netplan_config["vlans"][virtual_if.name] = {
634 "id": vlan_object.vid,
635 "link": virtual_if.label,
636 "addresses": [ip.address for ip in virtual_if_ips],
637 }
638
Wei-Yu Chen6ca7bc12021-07-19 19:53:43 +0800639 if routes:
640 self.netplan_config["vlans"][virtual_if.name]["routes"] = routes
641
Zack Williams5d66d182021-07-27 23:08:28 -0700642 return self.netplan_config
643
Wei-Yu Chenc517f552021-07-29 21:16:45 +0800644 def generate_nftables(self):
645
646 ret = dict()
647
648 primary_ip = self.data.primary_ip.address if self.data.primary_ip else None
649 external_if = self.interfaces_by_ip[primary_ip] if primary_ip else None
650 internal_if = None
651
652 if external_if is None:
653 logger.error("The primary interface wasn't set for device %s", self.name)
654 sys.exit(1)
655
656 for intf in filter(
657 lambda i: str(i.type) != "Virtual" and i.mgmt_only is False,
658 self.interfaces_by_ip.values(),
659 ):
660 if intf.id != external_if.id:
661 internal_if = intf
662 break
663
664 ret["external_if"] = external_if.name
665 ret["internal_if"] = internal_if.name
666
667 if self.services:
668 ret["services"] = list()
669
670 for service in self.services:
671 ret["services"].append(
672 {
673 "name": service.name,
674 "protocol": service.protocol.value,
675 "port": service.port,
676 }
677 )
678
679 # Only management server needs to be configured the whitelist netrange of internal interface
680 if self.data.device_role.name == "Router":
681 ret["allow_subnets"] = list()
682 ret["ue_routing"] = dict()
683 ret["ue_routing"]["ue_subnets"] = self.data.config_context["ue_subnets"]
684 for prefix in NBPrefix.all_prefixes().values():
685 if prefix.data.description:
686 ret["allow_subnets"].append(prefix.data.prefix)
687
688 if "fab" in prefix.data.description:
689 ret["ue_routing"].setdefault("src_subnets", [])
690 ret["ue_routing"]["src_subnets"].append(prefix.data.prefix)
691
692 if (
693 not ret["ue_routing"].get("snat_addr")
694 and "fab" in prefix.data.description
695 ):
696 for ip, device in prefix.aos.items():
697 if device.name == self.name:
698 ret["ue_routing"]["snat_addr"] = ip
699 break
700
701 return ret
702
Zack Williams5d66d182021-07-27 23:08:28 -0700703 def generate_extra_config(self):
Wei-Yu Chenc517f552021-07-29 21:16:45 +0800704 """
705 Generate the extra configs which need in management server configuration
706 This function should only be called when the device role is "Router"
707 """
Zack Williams5d66d182021-07-27 23:08:28 -0700708
709 if self.extra_config:
710 return self.extra_config
711
712 primary_ip = self.data.primary_ip.address if self.data.primary_ip else None
713
Wei-Yu Chenc517f552021-07-29 21:16:45 +0800714 service_names = list(map(lambda x: x.name, self.services))
Wei-Yu Chen8ade3302021-07-19 20:53:47 +0800715
Wei-Yu Chenc517f552021-07-29 21:16:45 +0800716 if "dns" in service_names:
717 unbound_listen_ips = []
718 unbound_allow_ips = []
Wei-Yu Chen8ade3302021-07-19 20:53:47 +0800719
Wei-Yu Chenc517f552021-07-29 21:16:45 +0800720 for ip, intf in self.interfaces_by_ip.items():
721 if ip != primary_ip and intf.mgmt_only == False:
722 unbound_listen_ips.append(ip)
Wei-Yu Chen8ade3302021-07-19 20:53:47 +0800723
Wei-Yu Chenc517f552021-07-29 21:16:45 +0800724 for prefix in NBPrefix.all_prefixes().values():
725 if prefix.data.description:
726 unbound_allow_ips.append(prefix.data.prefix)
Wei-Yu Chen8ade3302021-07-19 20:53:47 +0800727
Wei-Yu Chenc517f552021-07-29 21:16:45 +0800728 if unbound_listen_ips:
729 self.extra_config["unbound_listen_ips"] = unbound_listen_ips
Wei-Yu Chen8ade3302021-07-19 20:53:47 +0800730
Wei-Yu Chenc517f552021-07-29 21:16:45 +0800731 if unbound_allow_ips:
732 self.extra_config["unbound_allow_ips"] = unbound_allow_ips
Wei-Yu Chen8ade3302021-07-19 20:53:47 +0800733
Wei-Yu Chenc517f552021-07-29 21:16:45 +0800734 if "ntp" in service_names:
735 ntp_client_allow = []
Wei-Yu Chen8ade3302021-07-19 20:53:47 +0800736
Wei-Yu Chenc517f552021-07-29 21:16:45 +0800737 for prefix in NBPrefix.all_prefixes().values():
738 if prefix.data.description:
739 ntp_client_allow.append(prefix.data.prefix)
Wei-Yu Chen8ade3302021-07-19 20:53:47 +0800740
Wei-Yu Chenc517f552021-07-29 21:16:45 +0800741 if ntp_client_allow:
742 self.extra_config["ntp_client_allow"] = ntp_client_allow
Wei-Yu Chen8ade3302021-07-19 20:53:47 +0800743
Zack Williams5d66d182021-07-27 23:08:28 -0700744 return self.extra_config
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800745
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700746
747@yaml.yaml_object(ydump)
748class NBDevice(NBAssignedObject):
749 """
750 Wraps a single Netbox device
751 Also caches all known devices in a class variable (devs)
752 """
753
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800754 objects = dict()
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700755
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800756 def __init__(self, data):
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700757
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800758 super().__init__(data)
759 self.objects[self.id] = self
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700760
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800761 @property
762 def type(self):
763 return "NBDevice"
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700764
765 def get_interfaces(self):
766 if not self.interfaces:
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800767 self.interfaces = netboxapi.dcim.interfaces.filter(device_id=self.id)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700768
769 return self.interfaces
770
771 @classmethod
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800772 def get_by_id(cls, obj_id):
773 obj = cls.objects.get(obj_id, None)
774 obj = obj or NBDevice(netboxapi.dcim.devices.get(obj_id))
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700775
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800776 return obj
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700777
778
779@yaml.yaml_object(ydump)
780class NBVirtualMachine(NBAssignedObject):
781 """
782 VM equivalent of NBDevice
783 """
784
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800785 objects = dict()
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700786
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800787 def __init__(self, data):
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700788
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800789 super().__init__(data)
790 self.objects[self.id] = self
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700791
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800792 @property
793 def type(self):
794 return "NBVirtualMachine"
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700795
796 def get_interfaces(self):
797 if not self.interfaces:
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800798 self.interfaces = netboxapi.virtualization.interfaces.filter(
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700799 virtual_machine_id=self.id
800 )
801
802 return self.interfaces
803
804 @classmethod
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800805 def get_by_id(cls, obj_id):
806 obj = cls.objects.get(obj_id, None)
807 obj = obj or NBVirtualMachine(
808 netboxapi.virtualization.virtual_machines.get(obj_id)
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700809 )
810
Wei-Yu Chen55a86822021-07-08 14:34:59 +0800811 return obj
812
Zack Williams2aeb3ef2021-06-11 17:10:36 -0700813
814@yaml.yaml_object(ydump)
815class NBDNSForwardZone:
816
817 fwd_zones = {}
818
819 def __init__(self, prefix):
820
821 self.domain_extension = prefix.domain_extension
822
823 self.a_recs = {}
824 self.cname_recs = {}
825 self.srv_recs = {}
826 self.ns_recs = []
827 self.txt_recs = {}
828
829 if prefix.dhcp_range:
830 self.create_dhcp_fwd(prefix.dhcp_range)
831
832 for ip, ao in prefix.aos.items():
833 self.add_ao_records(prefix, ip, ao)
834
835 for ip, res in prefix.reserved_ips.items():
836 self.add_reserved(ip, res)
837
838 # reqquired for the add_fwd_cname function below
839 if callable(getattr(prefix, "parent")):
840 parent_prefix = prefix.parent()
841
842 if parent_prefix:
843 self.merge_parent_prefix(parent_prefix, prefix)
844
845 self.fwd_zones[self.domain_extension] = self
846
847 def __repr__(self):
848 return str(
849 {
850 "a": self.a_recs,
851 "cname": self.cname_recs,
852 "ns": self.ns_recs,
853 "srv": self.srv_recs,
854 "txt": self.txt_recs,
855 }
856 )
857
858 @classmethod
859 def add_fwd_cname(cls, cname, fqdn_dest):
860 """
861 Add an arbitrary CNAME (and possibly create the fwd zone if needed) pointing
862 at a FQDN destination name. It's used to support the per-IP "DNS name" field in NetBox
863 Note that the NS record
864 """
865
866 try:
867 fqdn_split = re.compile(r"([a-z]+)\.([a-z.]+)\.")
868 (short_name, extension) = fqdn_split.match(cname).groups()
869
870 except AttributeError:
871 logger.warning(
872 "Invalid DNS CNAME: '%s', must be in FQDN format: 'host.example.com.', ignored",
873 cname,
874 )
875 return
876
877 fake_prefix = AttrDict(
878 {
879 "domain_extension": extension,
880 "dhcp_range": None,
881 "aos": {},
882 "reserved_ips": {},
883 "parent": None,
884 }
885 )
886
887 fwd_zone = cls.get_fwd_zone(fake_prefix)
888
889 fwd_zone.cname_recs[short_name] = fqdn_dest
890
891 @classmethod
892 def get_fwd_zone(cls, prefix):
893 if prefix.domain_extension in cls.fwd_zones:
894 return cls.fwd_zones[prefix.domain_extension]
895
896 return NBDNSForwardZone(prefix)
897
898 @classmethod
899 def all_fwd_zones(cls):
900 return cls.fwd_zones
901
902 @classmethod
903 def to_yaml(cls, representer, node):
904 return representer.represent_dict(
905 {
906 "a": node.a_recs,
907 "cname": node.cname_recs,
908 "ns": node.ns_recs,
909 "srv": node.srv_recs,
910 "txt": node.txt_recs,
911 }
912 )
913
914 def fqdn(self, name):
915 return "%s.%s." % (name, self.domain_extension)
916
917 def create_dhcp_fwd(self, dhcp_range):
918
919 for ip in netaddr.IPNetwork(dhcp_range).iter_hosts():
920 self.a_recs["dhcp%03d" % (ip.words[3])] = str(ip)
921
922 def name_is_duplicate(self, name, target, record_type):
923 """
924 Returns True if name already exists in the zone as an A or CNAME
925 record, False otherwise
926 """
927
928 if name in self.a_recs:
929 logger.warning(
930 "Duplicate DNS record for name %s - A record to '%s', %s record to '%s'",
931 name,
932 self.a_recs[name],
933 record_type,
934 target,
935 )
936 return True
937
938 if name in self.cname_recs:
939 logger.warning(
940 "Duplicate DNS record for name %s - CNAME record to '%s', %s record to '%s'",
941 name,
942 self.cname_recs[name],
943 record_type,
944 target,
945 )
946 return True
947
948 return False
949
950 def add_ao_records(self, prefix, ip, ao):
951
952 name = ao.dns_name(ip, prefix)
953 target_ip = str(netaddr.IPNetwork(ip).ip) # make bare IP, not CIDR format
954
955 # add A records
956 if not self.name_is_duplicate(name, target_ip, "A"):
957 self.a_recs[name] = target_ip
958
959 # add CNAME records that alias to this name
960 for cname in ao.dns_cnames(ip):
961 # check that it isn't a dupe
962 if not self.name_is_duplicate(cname, target_ip, "CNAME"):
963 self.cname_recs[cname] = self.fqdn(name)
964
965 # add NS records if this is a DNS server
966 if ao.has_service(ip, 53, "udp"):
967 self.ns_recs.append(self.fqdn(name))
968
969 # if a DNS name is set, add it as a CNAME
970 if ao.ips[ip]["dns_name"]: # and ip == aos.data.primary_ip.address:
971 self.add_fwd_cname(ao.ips[ip]["dns_name"], self.fqdn(name))
972
973 def add_reserved(self, ip, res):
974
975 target_ip = str(netaddr.IPNetwork(ip).ip) # make bare IP, not CIDR format
976
977 if not self.name_is_duplicate(res["name"], target_ip, "A"):
978 self.a_recs[res["name"]] = target_ip
979
980 def merge_parent_prefix(self, pprefix, prefix):
981
982 # only if no NS records exist already
983 if not self.ns_recs:
984 # scan parent prefix for services
985 for ip, ao in pprefix.aos.items():
986
987 # Create a DNS within this prefix pointing to out-of-prefix IP
988 # where DNS server is
989 name = ao.dns_name(ip, prefix)
990 target_ip = str(
991 netaddr.IPNetwork(ip).ip
992 ) # make bare IP, not CIDR format
993
994 # add NS records if this is a DNS server
995 if ao.has_service(ip, 53, "udp"):
996 self.a_recs[name] = target_ip
997 self.ns_recs.append(self.fqdn(name))
998
999
1000@yaml.yaml_object(ydump)
1001class NBDNSReverseZones:
1002 def __init__(self):
1003
1004 self.reverse_zones = {}
1005
1006 @classmethod
1007 def to_yaml(cls, representer, node):
1008 return representer.represent_dict(node.reverse_zones)
1009
1010 @classmethod
1011 def canonicalize_rfc1918_prefix(cls, prefix):
1012 """
1013 RFC1918 prefixes need to be expanded to their widest canonical range to
1014 group all reverse lookup domains together for reverse DNS with NSD/Unbound.
1015 """
1016
1017 pnet = netaddr.IPNetwork(str(prefix))
1018 (o1, o2, o3, o4) = pnet.network.words # Split ipv4 octets
1019 cidr_plen = pnet.prefixlen
1020
1021 if o1 == 10:
1022 o2 = o3 = o4 = 0
1023 cidr_plen = 8
1024 elif (o1 == 172 and o2 >= 16 and o2 <= 31) or (o1 == 192 and o2 == 168):
1025 o3 = o4 = 0
1026 cidr_plen = 16
1027
1028 return "%s/%d" % (".".join(map(str, [o1, o2, o3, o4])), cidr_plen)
1029
1030 def add_prefix(self, prefix):
1031
1032 canonical_prefix = self.canonicalize_rfc1918_prefix(prefix)
1033
1034 if canonical_prefix in self.reverse_zones:
1035 rzone = self.reverse_zones[canonical_prefix]
1036 else:
1037 rzone = {
1038 "ns": [],
1039 "ptr": {},
1040 }
1041
1042 if prefix.dhcp_range:
1043 # FIXME: doesn't check for duplicate entries
1044 rzone["ptr"].update(self.create_dhcp_rev(prefix))
1045
1046 for ip, ao in prefix.aos.items():
1047 target_ip = str(netaddr.IPNetwork(ip).ip) # make bare IP, not CIDR format
1048 ao_name = self.get_ao_name(ip, ao, prefix,)
1049 rzone["ptr"][target_ip] = ao_name
1050
1051 # add NS records if this is a DNS server
1052 if ao.has_service(ip, 53, "udp"):
1053 rzone["ns"].append(ao_name)
1054
1055 parent_prefix = prefix.parent()
1056
1057 if parent_prefix:
1058 self.merge_parent_prefix(rzone, parent_prefix)
1059
1060 self.reverse_zones[canonical_prefix] = rzone
1061
1062 def merge_parent_prefix(self, rzone, pprefix):
1063
1064 # parent items
1065 p_ns = []
1066
1067 # scan parent prefix for services
1068 for ip, ao in pprefix.aos.items():
1069
1070 ao_name = self.get_ao_name(ip, ao, pprefix,)
1071
1072 # add NS records if this is a DNS server
1073 if ao.has_service(ip, 53, "udp"):
1074 p_ns.append(ao_name)
1075
1076 # set DNS servers if none in rzone
1077 if not rzone["ns"]:
1078 rzone["ns"] = p_ns
1079
1080 def create_dhcp_rev(self, prefix):
1081
1082 dhcp_rzone = {}
1083
1084 for ip in netaddr.IPNetwork(prefix.dhcp_range).iter_hosts():
1085 dhcp_rzone[str(ip)] = "dhcp%03d.%s." % (
1086 ip.words[3],
1087 prefix.domain_extension,
1088 )
1089
1090 return dhcp_rzone
1091
1092 def get_ao_name(self, ip, ao, prefix):
1093 short_name = ao.dns_name(ip, prefix)
1094 return "%s.%s." % (short_name, prefix.domain_extension)
1095
1096
1097@yaml.yaml_object(ydump)
1098class NBDHCPSubnet:
1099 def __init__(self, prefix):
1100
1101 self.domain_extension = prefix.domain_extension
1102
1103 self.subnet = None
1104 self.range = None
1105 self.first_ip = None
1106 self.hosts = []
1107 self.routers = []
1108 self.dns_servers = []
1109 self.dns_search = []
1110 self.tftpd_server = None
1111 self.ntp_servers = []
1112 self.dhcpd_interface = None
1113
1114 self.add_prefix(prefix)
1115
1116 for ip, ao in prefix.aos.items():
1117 self.add_ao(str(ip), ao, prefix)
1118
1119 parent_prefix = prefix.parent()
1120
1121 if parent_prefix:
1122 self.merge_parent_prefix(parent_prefix)
1123
1124 def add_prefix(self, prefix):
1125
1126 self.subnet = str(prefix)
1127
1128 self.first_ip = str(netaddr.IPAddress(netaddr.IPNetwork(str(prefix)).first + 1))
1129
1130 self.dns_search = [prefix.domain_extension]
1131
1132 if prefix.dhcp_range:
1133 self.range = prefix.dhcp_range
1134
1135 for ip, res in prefix.reserved_ips.items():
1136 # routers are reserved IP's that start with 'router" in the IP description
1137 if re.match("router", res["description"]):
1138 router = {"ip": str(netaddr.IPNetwork(ip).ip)}
1139
1140 if (
1141 "rfc3442routes" in res["custom_fields"]
1142 and res["custom_fields"]["rfc3442routes"]
1143 ):
1144 # split on whitespace
1145 router["rfc3442routes"] = re.split(
1146 r"\s+", res["custom_fields"]["rfc3442routes"]
1147 )
1148
1149 self.routers.append(router)
1150
1151 # set first IP to router if not set otherwise.
1152 if not self.routers:
1153 router = {"ip": self.first_ip}
1154
1155 self.routers.append(router)
1156
1157 def add_ao(self, ip, ao, prefix):
1158
1159 target_ip = str(netaddr.IPNetwork(ip).ip) # make bare IP, not CIDR format
1160
1161 # find the DHCP interface if it's this IP
1162 if target_ip == self.first_ip:
1163 self.dhcpd_interface = ao.interfaces_by_ip[ip].name
1164
1165 name = ao.dns_name(ip, prefix)
1166
1167 # add only devices that have a macaddr for this IP
1168 if ip in ao.interfaces_by_ip:
1169
1170 mac_addr = dict(ao.interfaces_by_ip[ip]).get("mac_address")
1171
1172 if mac_addr and mac_addr.strip(): # if exists and not blank
1173 self.hosts.append(
Wei-Yu Chenb43fc322021-07-21 14:50:16 +08001174 {"name": name, "ip_addr": target_ip, "mac_addr": mac_addr.lower()}
Zack Williams2aeb3ef2021-06-11 17:10:36 -07001175 )
1176
1177 # add dns servers
1178 if ao.has_service(ip, 53, "udp"):
1179 self.dns_servers.append(target_ip)
1180
1181 # add tftp server
1182 if ao.has_service(ip, 69, "udp"):
1183 if not self.tftpd_server:
1184 self.tftpd_server = target_ip
1185 else:
1186 logger.warning(
1187 "Duplicate TFTP servers in prefix, using first of %s and %s",
1188 self.tftpd_server,
1189 target_ip,
1190 )
1191
1192 # add NTP servers
1193 if ao.has_service(ip, 123, "udp"):
1194 self.ntp_servers.append(target_ip)
1195
1196 def merge_parent_prefix(self, pprefix):
1197
1198 # parent items
1199 p_dns_servers = []
1200 p_tftpd_server = None
1201 p_ntp_servers = []
1202
1203 # scan parent prefix for services
1204 for ip, ao in pprefix.aos.items():
1205
1206 target_ip = str(netaddr.IPNetwork(ip).ip)
1207
1208 # add dns servers
1209 if ao.has_service(ip, 53, "udp"):
1210 p_dns_servers.append(target_ip)
1211
1212 # add tftp server
1213 if ao.has_service(ip, 69, "udp"):
1214 if not p_tftpd_server:
1215 p_tftpd_server = target_ip
1216 else:
1217 logger.warning(
1218 "Duplicate TFTP servers in parent prefix, using first of %s and %s",
1219 p_tftpd_server,
1220 target_ip,
1221 )
1222
1223 # add NTP servers
1224 if ao.has_service(ip, 123, "udp"):
1225 p_ntp_servers.append(target_ip)
1226
1227 # merge if doesn't exist in prefix
1228 if not self.dns_servers:
1229 self.dns_servers = p_dns_servers
1230
1231 if not self.tftpd_server:
1232 self.tftpd_server = p_tftpd_server
1233
1234 if not self.ntp_servers:
1235 self.ntp_servers = p_ntp_servers
1236
1237 @classmethod
1238 def to_yaml(cls, representer, node):
1239 return representer.represent_dict(
1240 {
1241 "subnet": node.subnet,
1242 "range": node.range,
1243 "routers": node.routers,
1244 "hosts": node.hosts,
1245 "dns_servers": node.dns_servers,
1246 "dns_search": node.dns_search,
1247 "tftpd_server": node.tftpd_server,
1248 "ntp_servers": node.ntp_servers,
1249 }
1250 )