blob: 1a1e38584a8455090b5149e8b7b4e0c900044400 [file] [log] [blame]
Wei-Yu Chenbd495ba2021-08-31 19:46:35 +08001#!/usr/bin/env python3
2
3# SPDX-FileCopyrightText: © 2021 Open Networking Foundation <support@opennetworking.org>
4# SPDX-License-Identifier: Apache-2.0
5
6# device.py
Wei-Yu Chenbd495ba2021-08-31 19:46:35 +08007
Zack Williamsdac2be42021-08-19 16:14:31 -07008import sys
Wei-Yu Chenbd495ba2021-08-31 19:46:35 +08009import netaddr
10
Zack Williamsdac2be42021-08-19 16:14:31 -070011from .utils import logger
Wei-Yu Chenbd495ba2021-08-31 19:46:35 +080012from .container import DeviceContainer, VirtualMachineContainer, PrefixContainer
13
14
15class AssignedObject:
16 """
17 Assigned Object is either a Device or Virtual Machine, which function
18 nearly identically in the NetBox data model.
19
20 This parent class holds common functions for those two child classes
21
22 An assignedObject (device or VM) should have following attributes:
23 - self.data: contains the original copy of data from NetBox
24 - self.id: Device ID or VM ID
25 - self.interfaces: A dictionary contains interfaces belong to this AO
26 the interface dictionary looks like:
27
28 {
29 "eno1": {
30 "address": ["192.168.0.1/24", "192.168.0.2/24"],
31 "instance": <interface_instance>,
32 "isPrimary": True,
33 "mgmtOnly": False,
34 "isVirtual": False
35 }
36 }
37 """
38
39 objects = dict()
40
41 def __init__(self, data):
Zack Williamsdac2be42021-08-19 16:14:31 -070042 from .utils import netboxapi
Wei-Yu Chenbd495ba2021-08-31 19:46:35 +080043
44 self.data = data
45 self.nbapi = netboxapi
46
47 # The AssignedObject attributes
48 self.id = self.data.id
49 self.tenant = None
50 self.primary_ip = None
Zack Williamsdac2be42021-08-19 16:14:31 -070051 self.primary_iface = None
Wei-Yu Chenbd495ba2021-08-31 19:46:35 +080052
53 # In Netbox, we use FQDN as the Device name, but in the script,
54 # we use the first segment to be the name of device.
55 # For example, if the device named "mgmtserver1.stage1.menlo" on Netbox,
56 # then we will have "mgmtserver1" as name.
57 self.fullname = self.data.name
58 self.name = self.fullname.split(".")[0]
59
60 # The device role which can be ["server", "router", "switch", ...]
61 self.role = None
62
63 # The NetBox objects related with this AssignedObject
64 self.interfaces = dict()
65 self.services = None
66
67 # Generated configuration for ansible playbooks
68 self.netplan_config = dict()
69 self.extra_config = dict()
70
71 if self.__class__ == Device:
72 self.role = self.data.device_role.slug
73 self.services = self.nbapi.ipam.services.filter(device_id=self.id)
74 interfaces = self.nbapi.dcim.interfaces.filter(device_id=self.id)
75 ip_addresses = self.nbapi.ipam.ip_addresses.filter(device_id=self.id)
76 elif self.__class__ == VirtualMachine:
77 self.role = self.data.role.slug
78 self.services = self.nbapi.ipam.services.filter(virtual_machine_id=self.id)
79 interfaces = self.nbapi.virtualization.interfaces.filter(
80 virtual_machine_id=self.id
81 )
82 ip_addresses = self.nbapi.ipam.ip_addresses.filter(
83 virtual_machine_id=self.id
84 )
85
86 self.primary_ip = self.data.primary_ip
87
88 for interface in interfaces:
89 # The Device's interface structure is different from VM's interface
90 # VM interface doesn't have mgmt_only and type, Therefore,
91 # the default value of mgmtOnly is False, isVirtual is True
92
93 self.interfaces[interface.name] = {
94 "addresses": list(),
95 "mac_address": interface.mac_address,
96 "instance": interface,
97 "isPrimary": False,
98 "mgmtOnly": getattr(interface, "mgmt_only", False),
99 "isVirtual": interface.type.value == "virtual"
100 if hasattr(interface, "type")
101 else True,
102 }
103
104 for address in ip_addresses:
105 interface = self.interfaces[address.assigned_object.name]
106 interface["addresses"].append(address.address)
107
108 # ipam.ip_addresses doesn't have primary tag,
109 # the primary tag is only available is only in the Device.
110 # So we need to compare address to check which one is primary ip
Zack Williamsdac2be42021-08-19 16:14:31 -0700111 try:
112 if address.address == self.primary_ip.address:
113 interface["isPrimary"] = True
114 self.primary_iface = interface
115 except AttributeError:
116 logger.error("Error with primary address for device %s", self.fullname)
Wei-Yu Chenbd495ba2021-08-31 19:46:35 +0800117
118 # mgmt_only = False is a hack for VirtualMachine type
119 if self.__class__ == VirtualMachine:
120 interface["instance"].mgmt_only = False
121
122 def __repr__(self):
123 return str(dict(self.data))
124
125 @property
126 def type(self):
127 return "AssignedObject"
128
129 @property
130 def internal_interfaces(self):
131 """
132 The function internal_interfaces
133 """
134
135 ret = dict()
136 for intfName, interface in self.interfaces.items():
137 if (
138 not interface["isPrimary"]
139 and not interface["mgmtOnly"]
140 and interface["addresses"]
141 ):
142 ret[intfName] = interface
143
144 return ret
145
146 def generate_netplan(self):
147 """
148 Get the interface config of specific server belongs to this tenant
149 """
150
151 if self.netplan_config:
152 return self.netplan_config
153
154 primary_if = None
155 for interface in self.interfaces.values():
156 if interface["isPrimary"] is True:
157 primary_if = interface["instance"]
158
159 if primary_if is None:
160 logger.error("The primary interface wasn't set for device %s", self.name)
161 return dict()
162
163 # Initialize the part of "ethernets" configuration
164 self.netplan_config["ethernets"] = dict()
165
166 # If the current selected device is a Router
167 if (isinstance(self, Device) and self.data.device_role.name == "Router") or (
168 isinstance(self, VirtualMachine) and self.data.role.name == "Router"
169 ):
170 for intfName, interface in self.interfaces.items():
171 if interface["mgmtOnly"] or interface["isVirtual"]:
172 continue
173
174 # Check if this address is public IP address (e.g. "8.8.8.8" on eth0)
175 isExternalAddress = True
176 for prefix in PrefixContainer().all():
177 for address in interface["addresses"]:
178 if address in netaddr.IPSet([prefix.subnet]):
179 isExternalAddress = False
180
181 # If this interface has the public IP address, netplan shouldn't include it
182 if isExternalAddress:
183 continue
184
185 self.netplan_config["ethernets"].setdefault(intfName, {})
186 self.netplan_config["ethernets"][intfName].setdefault(
187 "addresses", []
Zack Williamsdac2be42021-08-19 16:14:31 -0700188 ).extend(interface["addresses"])
Wei-Yu Chenbd495ba2021-08-31 19:46:35 +0800189
190 # If the current selected device is a Server
191 elif isinstance(self, Device) and self.data.device_role.name == "Server":
192 if primary_if:
193 self.netplan_config["ethernets"][primary_if.name] = {
194 "dhcp4": "yes",
195 "dhcp4-overrides": {"route-metric": 100},
196 }
197
198 for intfName, interface in self.interfaces.items():
199 if (
200 not interface["isVirtual"]
201 and intfName != primary_if.name
202 and not interface["mgmtOnly"]
203 and interface["addresses"]
204 ):
205 self.netplan_config["ethernets"][intfName] = {
206 "dhcp4": "yes",
207 "dhcp4-overrides": {"route-metric": 200},
208 }
209
210 else:
211 # Exclude the device type which is not Router and Server
212 return None
213
214 # Get interfaces own by AssignedObject and is virtual (VLAN interface)
215 for intfName, interface in self.interfaces.items():
216
217 # If the interface is not a virtual interface or
218 # the interface doesn't have VLAN tagged, skip this interface
219 if not interface["isVirtual"] or not interface["instance"].tagged_vlans:
220 continue
221
222 if "vlans" not in self.netplan_config:
223 self.netplan_config["vlans"] = dict()
224
225 vlan_object_id = interface["instance"].tagged_vlans[0].id
226 vlan_object = self.nbapi.ipam.vlans.get(vlan_object_id)
227
228 routes = list()
229 for address in interface["addresses"]:
230
231 for reserved_ip in PrefixContainer().all_reserved_ips(address):
232
233 destination = reserved_ip["custom_fields"].get("rfc3442routes", "")
234 if not destination:
235 continue
236
Wei-Yu Chenc7d68312021-09-14 17:12:34 +0800237 for dest_addr in destination.split(","):
Wei-Yu Chenbd495ba2021-08-31 19:46:35 +0800238
Wei-Yu Chenc7d68312021-09-14 17:12:34 +0800239 # If interface address is in destination subnet, we don't need this route
Zack Williamsdac2be42021-08-19 16:14:31 -0700240 if netaddr.IPNetwork(address).ip in netaddr.IPNetwork(
241 dest_addr
242 ):
Wei-Yu Chenc7d68312021-09-14 17:12:34 +0800243 continue
244
Wei-Yu Chenbd495ba2021-08-31 19:46:35 +0800245 new_route = {
246 "to": dest_addr,
247 "via": str(netaddr.IPNetwork(reserved_ip["ip4"]).ip),
248 "metric": 100,
249 }
250
251 if new_route not in routes:
252 routes.append(new_route)
253
254 self.netplan_config["vlans"][intfName] = {
255 "id": vlan_object.vid,
256 "link": interface["instance"].label,
257 "addresses": interface["addresses"],
258 }
259
260 # Only the fabric virtual interface will need to route to other network segments
261 if routes and "fab" in intfName:
262 self.netplan_config["vlans"][intfName]["routes"] = routes
263
264 return self.netplan_config
265
266 def generate_nftables(self):
267
268 ret = dict()
269
270 internal_if = None
271 external_if = None
272
273 # Use isPrimary == True as the identifier to select external interface
274 for interface in self.interfaces.values():
275 if interface["isPrimary"] is True:
276 external_if = interface["instance"]
277
278 if external_if is None:
279 logger.error("The primary interface wasn't set for device %s", self.name)
280 sys.exit(1)
281
282 for interface in self.interfaces.values():
283 # If "isVirtual" set to False and "mgmtOnly" set to False
284 if (
285 not interface["isVirtual"]
286 and not interface["mgmtOnly"]
287 and interface["instance"] is not external_if
288 ):
289 internal_if = interface["instance"]
290 break
291
292 ret["external_if"] = external_if.name
293 ret["internal_if"] = internal_if.name
294
295 if self.services:
296 ret["services"] = list()
297
298 for service in self.services:
299 ret["services"].append(
300 {
301 "name": service.name,
302 "protocol": service.protocol.value,
303 "port": service.port,
304 }
305 )
306
Zack Williamsdac2be42021-08-19 16:14:31 -0700307 # Only management server needs to be configured the whitelist netrange of
Wei-Yu Chendd2598c2021-09-28 10:14:27 +0800308 # internal interface, this code will config the nftables parameters
309 # the nftables will do the SNAT for both UE ranges and Aether Central ranges
Wei-Yu Chenbd495ba2021-08-31 19:46:35 +0800310 if self.data.device_role.name == "Router":
311
312 ret["interface_subnets"] = dict()
Wei-Yu Chendd2598c2021-09-28 10:14:27 +0800313
314 ret["acc_routing"] = dict()
315 ret["acc_routing"]["acc_subnets"] = self.data.config_context.pop(
316 "acc_subnets"
317 )
318
Wei-Yu Chenbd495ba2021-08-31 19:46:35 +0800319 ret["ue_routing"] = dict()
Wei-Yu Chen9b55d362021-09-22 11:04:31 +0800320 ret["ue_routing"]["ue_subnets"] = self.data.config_context.pop("ue_subnets")
Wei-Yu Chenbd495ba2021-08-31 19:46:35 +0800321
322 # Create the interface_subnets in the configuration
323 # It's using the interface as the key to list IP addresses
324 for intfName, interface in self.interfaces.items():
325 if interface["mgmtOnly"]:
326 continue
327
328 for address in interface["addresses"]:
329 for prefix in PrefixContainer().all():
330 intfAddr = netaddr.IPNetwork(address).ip
331
332 # If interface IP doesn't belong to this prefix, skip
333 if intfAddr not in netaddr.IPNetwork(prefix.subnet):
334 continue
335
336 # If prefix is a parent prefix (parent prefix won't config domain name)
337 # skip to add in interface_subnets
338 if not prefix.data.description:
339 continue
340
341 ret["interface_subnets"].setdefault(intfName, list())
342
343 if prefix.subnet not in ret["interface_subnets"][intfName]:
344 ret["interface_subnets"][intfName].append(prefix.subnet)
345 for neighbor in prefix.neighbor:
Zack Williamsdac2be42021-08-19 16:14:31 -0700346 if (
347 neighbor.subnet
348 not in ret["interface_subnets"][intfName]
349 ):
350 ret["interface_subnets"][intfName].append(
351 neighbor.subnet
352 )
Wei-Yu Chenbd495ba2021-08-31 19:46:35 +0800353
Wei-Yu Chendd2598c2021-09-28 10:14:27 +0800354 # Build data which needs by nftables, the UE subnets and ACC subnets
Wei-Yu Chenbd495ba2021-08-31 19:46:35 +0800355 for prefix in PrefixContainer().all():
356
Wei-Yu Chendd2598c2021-09-28 10:14:27 +0800357 # The subnet in this site which needs the redirecting
Wei-Yu Chenbd495ba2021-08-31 19:46:35 +0800358 if "fab" in prefix.data.description:
359 ret["ue_routing"].setdefault("src_subnets", [])
360 ret["ue_routing"]["src_subnets"].append(prefix.data.prefix)
Wei-Yu Chendd2598c2021-09-28 10:14:27 +0800361 ret["acc_routing"].setdefault("src_subnets", [])
362 ret["acc_routing"]["src_subnets"].append(prefix.data.prefix)
Wei-Yu Chenbd495ba2021-08-31 19:46:35 +0800363
Wei-Yu Chendd2598c2021-09-28 10:14:27 +0800364 # mgmtserver do the SNAT for fabric network on FAB interface
Wei-Yu Chenbd495ba2021-08-31 19:46:35 +0800365 if (
366 not ret["ue_routing"].get("snat_addr")
367 and "fab" in prefix.data.description
368 ):
369 for interface in self.interfaces.values():
370 for address in interface["addresses"]:
371 if address in netaddr.IPSet([prefix.subnet]):
372 ret["ue_routing"]["snat_addr"] = str(
373 netaddr.IPNetwork(address).ip
374 )
375 break
376
Wei-Yu Chendd2598c2021-09-28 10:14:27 +0800377 # mgmtserver do the SNAT for mgmt network on mgmt interface
378 if (
379 not ret["acc_routing"].get("snat_addr")
380 and "mgmt" in prefix.data.description
381 ):
382 for interface in self.interfaces.values():
383 for address in interface["addresses"]:
384 if address in netaddr.IPSet([prefix.subnet]):
385 ret["acc_routing"]["snat_addr"] = str(
386 netaddr.IPNetwork(address).ip
387 )
388 break
389
Wei-Yu Chenbd495ba2021-08-31 19:46:35 +0800390 return ret
391
392 def generate_extra_config(self):
393 """
394 Generate the extra configs which need in management server configuration
395 This function should only be called when the device role is "Router"
Wei-Yu Chen9b55d362021-09-22 11:04:31 +0800396
397 Extra config includes: service configuring parameters, additional config context
Wei-Yu Chenbd495ba2021-08-31 19:46:35 +0800398 """
399
400 if self.extra_config:
401 return self.extra_config
402
Wei-Yu Chenbd495ba2021-08-31 19:46:35 +0800403 service_names = list(map(lambda x: x.name, self.services))
404
405 if "dns" in service_names:
406 unbound_listen_ips = []
407 unbound_allow_ips = []
408
409 for interface in self.interfaces.values():
410 if not interface["isPrimary"] and not interface["mgmtOnly"]:
411 for address in interface["addresses"]:
412 unbound_listen_ips.append(address)
413
414 for prefix in PrefixContainer().all():
415 if prefix.data.description:
416 unbound_allow_ips.append(prefix.data.prefix)
417
418 if unbound_listen_ips:
419 self.extra_config["unbound_listen_ips"] = unbound_listen_ips
420
421 if unbound_allow_ips:
422 self.extra_config["unbound_allow_ips"] = unbound_allow_ips
423
424 if "ntp" in service_names:
425 ntp_client_allow = []
426
427 for prefix in PrefixContainer().all():
428 if prefix.data.description:
429 ntp_client_allow.append(prefix.data.prefix)
430
431 if ntp_client_allow:
432 self.extra_config["ntp_client_allow"] = ntp_client_allow
433
Wei-Yu Chen9b55d362021-09-22 11:04:31 +0800434 # If the key exists in generated config, warning with the key name
435 for key in self.data.config_context.keys():
436 if key in self.extra_config:
437 logger.warning("Extra config Key %s was overwritten", key)
438
439 self.extra_config.update(self.data.config_context)
440
Wei-Yu Chenbd495ba2021-08-31 19:46:35 +0800441 return self.extra_config
442
443
444class Device(AssignedObject):
445 """
446 Wraps a single Netbox device
447 Also caches all known devices in a class variable (devs)
448 """
449
450 def __init__(self, data):
451
452 super().__init__(data)
453 DeviceContainer().add(self.id, self)
454
455 @property
456 def type(self):
457 return "Device"
458
459 def get_interfaces(self):
460 if not self.interfaces:
461 self.interfaces = self.nbapi.dcim.interfaces.filter(device_id=self.id)
462
463 return self.interfaces
464
465
466class VirtualMachine(AssignedObject):
467 """
468 VM equivalent of Device
469 """
470
471 def __init__(self, data):
472
473 super().__init__(data)
474 VirtualMachineContainer().add(self.id, self)
475
476 @property
477 def type(self):
478 return "VirtualMachine"
479
480 def get_interfaces(self):
481 if not self.interfaces:
482 self.interfaces = self.nbapi.virtualization.interfaces.filter(
483 virtual_machine_id=self.id
484 )
485
486 return self.interfaces