Wei-Yu Chen | bd495ba | 2021-08-31 19:46:35 +0800 | [diff] [blame] | 1 | #!/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 Chen | bd495ba | 2021-08-31 19:46:35 +0800 | [diff] [blame] | 7 | |
Zack Williams | dac2be4 | 2021-08-19 16:14:31 -0700 | [diff] [blame] | 8 | import sys |
Wei-Yu Chen | bd495ba | 2021-08-31 19:46:35 +0800 | [diff] [blame] | 9 | import netaddr |
| 10 | |
Zack Williams | dac2be4 | 2021-08-19 16:14:31 -0700 | [diff] [blame] | 11 | from .utils import logger |
Wei-Yu Chen | bd495ba | 2021-08-31 19:46:35 +0800 | [diff] [blame] | 12 | from .container import DeviceContainer, VirtualMachineContainer, PrefixContainer |
| 13 | |
| 14 | |
| 15 | class 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 Williams | dac2be4 | 2021-08-19 16:14:31 -0700 | [diff] [blame] | 42 | from .utils import netboxapi |
Wei-Yu Chen | bd495ba | 2021-08-31 19:46:35 +0800 | [diff] [blame] | 43 | |
| 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 Williams | dac2be4 | 2021-08-19 16:14:31 -0700 | [diff] [blame] | 51 | self.primary_iface = None |
Wei-Yu Chen | bd495ba | 2021-08-31 19:46:35 +0800 | [diff] [blame] | 52 | |
| 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 Williams | dac2be4 | 2021-08-19 16:14:31 -0700 | [diff] [blame] | 111 | 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 Chen | bd495ba | 2021-08-31 19:46:35 +0800 | [diff] [blame] | 117 | |
| 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 Williams | dac2be4 | 2021-08-19 16:14:31 -0700 | [diff] [blame] | 188 | ).extend(interface["addresses"]) |
Wei-Yu Chen | bd495ba | 2021-08-31 19:46:35 +0800 | [diff] [blame] | 189 | |
| 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 Chen | c7d6831 | 2021-09-14 17:12:34 +0800 | [diff] [blame] | 237 | for dest_addr in destination.split(","): |
Wei-Yu Chen | bd495ba | 2021-08-31 19:46:35 +0800 | [diff] [blame] | 238 | |
Wei-Yu Chen | c7d6831 | 2021-09-14 17:12:34 +0800 | [diff] [blame] | 239 | # If interface address is in destination subnet, we don't need this route |
Zack Williams | dac2be4 | 2021-08-19 16:14:31 -0700 | [diff] [blame] | 240 | if netaddr.IPNetwork(address).ip in netaddr.IPNetwork( |
| 241 | dest_addr |
| 242 | ): |
Wei-Yu Chen | c7d6831 | 2021-09-14 17:12:34 +0800 | [diff] [blame] | 243 | continue |
| 244 | |
Wei-Yu Chen | bd495ba | 2021-08-31 19:46:35 +0800 | [diff] [blame] | 245 | 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 Williams | dac2be4 | 2021-08-19 16:14:31 -0700 | [diff] [blame] | 307 | # Only management server needs to be configured the whitelist netrange of |
Wei-Yu Chen | dd2598c | 2021-09-28 10:14:27 +0800 | [diff] [blame] | 308 | # 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 Chen | bd495ba | 2021-08-31 19:46:35 +0800 | [diff] [blame] | 310 | if self.data.device_role.name == "Router": |
| 311 | |
| 312 | ret["interface_subnets"] = dict() |
Wei-Yu Chen | dd2598c | 2021-09-28 10:14:27 +0800 | [diff] [blame] | 313 | |
| 314 | ret["acc_routing"] = dict() |
| 315 | ret["acc_routing"]["acc_subnets"] = self.data.config_context.pop( |
| 316 | "acc_subnets" |
| 317 | ) |
| 318 | |
Wei-Yu Chen | bd495ba | 2021-08-31 19:46:35 +0800 | [diff] [blame] | 319 | ret["ue_routing"] = dict() |
Wei-Yu Chen | 9b55d36 | 2021-09-22 11:04:31 +0800 | [diff] [blame] | 320 | ret["ue_routing"]["ue_subnets"] = self.data.config_context.pop("ue_subnets") |
Wei-Yu Chen | bd495ba | 2021-08-31 19:46:35 +0800 | [diff] [blame] | 321 | |
| 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 Williams | dac2be4 | 2021-08-19 16:14:31 -0700 | [diff] [blame] | 346 | if ( |
| 347 | neighbor.subnet |
| 348 | not in ret["interface_subnets"][intfName] |
| 349 | ): |
| 350 | ret["interface_subnets"][intfName].append( |
| 351 | neighbor.subnet |
| 352 | ) |
Wei-Yu Chen | bd495ba | 2021-08-31 19:46:35 +0800 | [diff] [blame] | 353 | |
Wei-Yu Chen | dd2598c | 2021-09-28 10:14:27 +0800 | [diff] [blame] | 354 | # Build data which needs by nftables, the UE subnets and ACC subnets |
Wei-Yu Chen | bd495ba | 2021-08-31 19:46:35 +0800 | [diff] [blame] | 355 | for prefix in PrefixContainer().all(): |
| 356 | |
Wei-Yu Chen | dd2598c | 2021-09-28 10:14:27 +0800 | [diff] [blame] | 357 | # The subnet in this site which needs the redirecting |
Wei-Yu Chen | bd495ba | 2021-08-31 19:46:35 +0800 | [diff] [blame] | 358 | 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 Chen | dd2598c | 2021-09-28 10:14:27 +0800 | [diff] [blame] | 361 | ret["acc_routing"].setdefault("src_subnets", []) |
| 362 | ret["acc_routing"]["src_subnets"].append(prefix.data.prefix) |
Wei-Yu Chen | bd495ba | 2021-08-31 19:46:35 +0800 | [diff] [blame] | 363 | |
Wei-Yu Chen | dd2598c | 2021-09-28 10:14:27 +0800 | [diff] [blame] | 364 | # mgmtserver do the SNAT for fabric network on FAB interface |
Wei-Yu Chen | bd495ba | 2021-08-31 19:46:35 +0800 | [diff] [blame] | 365 | 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 Chen | dd2598c | 2021-09-28 10:14:27 +0800 | [diff] [blame] | 377 | # 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 Chen | bd495ba | 2021-08-31 19:46:35 +0800 | [diff] [blame] | 390 | 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 Chen | 9b55d36 | 2021-09-22 11:04:31 +0800 | [diff] [blame] | 396 | |
| 397 | Extra config includes: service configuring parameters, additional config context |
Wei-Yu Chen | bd495ba | 2021-08-31 19:46:35 +0800 | [diff] [blame] | 398 | """ |
| 399 | |
| 400 | if self.extra_config: |
| 401 | return self.extra_config |
| 402 | |
Wei-Yu Chen | bd495ba | 2021-08-31 19:46:35 +0800 | [diff] [blame] | 403 | 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 Chen | 9b55d36 | 2021-09-22 11:04:31 +0800 | [diff] [blame] | 434 | # 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 Chen | bd495ba | 2021-08-31 19:46:35 +0800 | [diff] [blame] | 441 | return self.extra_config |
| 442 | |
| 443 | |
| 444 | class 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 | |
| 466 | class 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 |