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 |
| 7 | # |
| 8 | |
| 9 | import netaddr |
| 10 | |
| 11 | from .utils import logger, clean_name_dns |
| 12 | from .network import Prefix |
| 13 | from .container import DeviceContainer, VirtualMachineContainer, PrefixContainer |
| 14 | |
| 15 | |
| 16 | class AssignedObject: |
| 17 | """ |
| 18 | Assigned Object is either a Device or Virtual Machine, which function |
| 19 | nearly identically in the NetBox data model. |
| 20 | |
| 21 | This parent class holds common functions for those two child classes |
| 22 | |
| 23 | An assignedObject (device or VM) should have following attributes: |
| 24 | - self.data: contains the original copy of data from NetBox |
| 25 | - self.id: Device ID or VM ID |
| 26 | - self.interfaces: A dictionary contains interfaces belong to this AO |
| 27 | the interface dictionary looks like: |
| 28 | |
| 29 | { |
| 30 | "eno1": { |
| 31 | "address": ["192.168.0.1/24", "192.168.0.2/24"], |
| 32 | "instance": <interface_instance>, |
| 33 | "isPrimary": True, |
| 34 | "mgmtOnly": False, |
| 35 | "isVirtual": False |
| 36 | } |
| 37 | } |
| 38 | """ |
| 39 | |
| 40 | objects = dict() |
| 41 | |
| 42 | def __init__(self, data): |
| 43 | from .utils import netboxapi, netbox_config |
| 44 | |
| 45 | self.data = data |
| 46 | self.nbapi = netboxapi |
| 47 | |
| 48 | # The AssignedObject attributes |
| 49 | self.id = self.data.id |
| 50 | self.tenant = None |
| 51 | self.primary_ip = None |
| 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 |
| 111 | if address.address == self.primary_ip.address: |
| 112 | interface["isPrimary"] = True |
| 113 | |
| 114 | # mgmt_only = False is a hack for VirtualMachine type |
| 115 | if self.__class__ == VirtualMachine: |
| 116 | interface["instance"].mgmt_only = False |
| 117 | |
| 118 | def __repr__(self): |
| 119 | return str(dict(self.data)) |
| 120 | |
| 121 | @property |
| 122 | def type(self): |
| 123 | return "AssignedObject" |
| 124 | |
| 125 | @property |
| 126 | def internal_interfaces(self): |
| 127 | """ |
| 128 | The function internal_interfaces |
| 129 | """ |
| 130 | |
| 131 | ret = dict() |
| 132 | for intfName, interface in self.interfaces.items(): |
| 133 | if ( |
| 134 | not interface["isPrimary"] |
| 135 | and not interface["mgmtOnly"] |
| 136 | and interface["addresses"] |
| 137 | ): |
| 138 | ret[intfName] = interface |
| 139 | |
| 140 | return ret |
| 141 | |
| 142 | def generate_netplan(self): |
| 143 | """ |
| 144 | Get the interface config of specific server belongs to this tenant |
| 145 | """ |
| 146 | |
| 147 | if self.netplan_config: |
| 148 | return self.netplan_config |
| 149 | |
| 150 | primary_if = None |
| 151 | for interface in self.interfaces.values(): |
| 152 | if interface["isPrimary"] is True: |
| 153 | primary_if = interface["instance"] |
| 154 | |
| 155 | if primary_if is None: |
| 156 | logger.error("The primary interface wasn't set for device %s", self.name) |
| 157 | return dict() |
| 158 | |
| 159 | # Initialize the part of "ethernets" configuration |
| 160 | self.netplan_config["ethernets"] = dict() |
| 161 | |
| 162 | # If the current selected device is a Router |
| 163 | if (isinstance(self, Device) and self.data.device_role.name == "Router") or ( |
| 164 | isinstance(self, VirtualMachine) and self.data.role.name == "Router" |
| 165 | ): |
| 166 | for intfName, interface in self.interfaces.items(): |
| 167 | if interface["mgmtOnly"] or interface["isVirtual"]: |
| 168 | continue |
| 169 | |
| 170 | # Check if this address is public IP address (e.g. "8.8.8.8" on eth0) |
| 171 | isExternalAddress = True |
| 172 | for prefix in PrefixContainer().all(): |
| 173 | for address in interface["addresses"]: |
| 174 | if address in netaddr.IPSet([prefix.subnet]): |
| 175 | isExternalAddress = False |
| 176 | |
| 177 | # If this interface has the public IP address, netplan shouldn't include it |
| 178 | if isExternalAddress: |
| 179 | continue |
| 180 | |
| 181 | self.netplan_config["ethernets"].setdefault(intfName, {}) |
| 182 | self.netplan_config["ethernets"][intfName].setdefault( |
| 183 | "addresses", [] |
| 184 | ).append(address) |
| 185 | |
| 186 | # If the current selected device is a Server |
| 187 | elif isinstance(self, Device) and self.data.device_role.name == "Server": |
| 188 | if primary_if: |
| 189 | self.netplan_config["ethernets"][primary_if.name] = { |
| 190 | "dhcp4": "yes", |
| 191 | "dhcp4-overrides": {"route-metric": 100}, |
| 192 | } |
| 193 | |
| 194 | for intfName, interface in self.interfaces.items(): |
| 195 | if ( |
| 196 | not interface["isVirtual"] |
| 197 | and intfName != primary_if.name |
| 198 | and not interface["mgmtOnly"] |
| 199 | and interface["addresses"] |
| 200 | ): |
| 201 | self.netplan_config["ethernets"][intfName] = { |
| 202 | "dhcp4": "yes", |
| 203 | "dhcp4-overrides": {"route-metric": 200}, |
| 204 | } |
| 205 | |
| 206 | else: |
| 207 | # Exclude the device type which is not Router and Server |
| 208 | return None |
| 209 | |
| 210 | # Get interfaces own by AssignedObject and is virtual (VLAN interface) |
| 211 | for intfName, interface in self.interfaces.items(): |
| 212 | |
| 213 | # If the interface is not a virtual interface or |
| 214 | # the interface doesn't have VLAN tagged, skip this interface |
| 215 | if not interface["isVirtual"] or not interface["instance"].tagged_vlans: |
| 216 | continue |
| 217 | |
| 218 | if "vlans" not in self.netplan_config: |
| 219 | self.netplan_config["vlans"] = dict() |
| 220 | |
| 221 | vlan_object_id = interface["instance"].tagged_vlans[0].id |
| 222 | vlan_object = self.nbapi.ipam.vlans.get(vlan_object_id) |
| 223 | |
| 224 | routes = list() |
| 225 | for address in interface["addresses"]: |
| 226 | |
| 227 | for reserved_ip in PrefixContainer().all_reserved_ips(address): |
| 228 | |
| 229 | destination = reserved_ip["custom_fields"].get("rfc3442routes", "") |
| 230 | if not destination: |
| 231 | continue |
| 232 | |
Wei-Yu Chen | c7d6831 | 2021-09-14 17:12:34 +0800 | [diff] [blame^] | 233 | for dest_addr in destination.split(","): |
Wei-Yu Chen | bd495ba | 2021-08-31 19:46:35 +0800 | [diff] [blame] | 234 | |
Wei-Yu Chen | c7d6831 | 2021-09-14 17:12:34 +0800 | [diff] [blame^] | 235 | # If interface address is in destination subnet, we don't need this route |
| 236 | if netaddr.IPNetwork(address).ip in netaddr.IPNetwork(dest_addr): |
| 237 | continue |
| 238 | |
Wei-Yu Chen | bd495ba | 2021-08-31 19:46:35 +0800 | [diff] [blame] | 239 | new_route = { |
| 240 | "to": dest_addr, |
| 241 | "via": str(netaddr.IPNetwork(reserved_ip["ip4"]).ip), |
| 242 | "metric": 100, |
| 243 | } |
| 244 | |
| 245 | if new_route not in routes: |
| 246 | routes.append(new_route) |
| 247 | |
| 248 | self.netplan_config["vlans"][intfName] = { |
| 249 | "id": vlan_object.vid, |
| 250 | "link": interface["instance"].label, |
| 251 | "addresses": interface["addresses"], |
| 252 | } |
| 253 | |
| 254 | # Only the fabric virtual interface will need to route to other network segments |
| 255 | if routes and "fab" in intfName: |
| 256 | self.netplan_config["vlans"][intfName]["routes"] = routes |
| 257 | |
| 258 | return self.netplan_config |
| 259 | |
| 260 | def generate_nftables(self): |
| 261 | |
| 262 | ret = dict() |
| 263 | |
| 264 | internal_if = None |
| 265 | external_if = None |
| 266 | |
| 267 | # Use isPrimary == True as the identifier to select external interface |
| 268 | for interface in self.interfaces.values(): |
| 269 | if interface["isPrimary"] is True: |
| 270 | external_if = interface["instance"] |
| 271 | |
| 272 | if external_if is None: |
| 273 | logger.error("The primary interface wasn't set for device %s", self.name) |
| 274 | sys.exit(1) |
| 275 | |
| 276 | for interface in self.interfaces.values(): |
| 277 | # If "isVirtual" set to False and "mgmtOnly" set to False |
| 278 | if ( |
| 279 | not interface["isVirtual"] |
| 280 | and not interface["mgmtOnly"] |
| 281 | and interface["instance"] is not external_if |
| 282 | ): |
| 283 | internal_if = interface["instance"] |
| 284 | break |
| 285 | |
| 286 | ret["external_if"] = external_if.name |
| 287 | ret["internal_if"] = internal_if.name |
| 288 | |
| 289 | if self.services: |
| 290 | ret["services"] = list() |
| 291 | |
| 292 | for service in self.services: |
| 293 | ret["services"].append( |
| 294 | { |
| 295 | "name": service.name, |
| 296 | "protocol": service.protocol.value, |
| 297 | "port": service.port, |
| 298 | } |
| 299 | ) |
| 300 | |
| 301 | # Only management server needs to be configured the whitelist netrange of internal interface |
| 302 | if self.data.device_role.name == "Router": |
| 303 | |
| 304 | ret["interface_subnets"] = dict() |
| 305 | ret["ue_routing"] = dict() |
| 306 | ret["ue_routing"]["ue_subnets"] = self.data.config_context["ue_subnets"] |
| 307 | |
| 308 | # Create the interface_subnets in the configuration |
| 309 | # It's using the interface as the key to list IP addresses |
| 310 | for intfName, interface in self.interfaces.items(): |
| 311 | if interface["mgmtOnly"]: |
| 312 | continue |
| 313 | |
| 314 | for address in interface["addresses"]: |
| 315 | for prefix in PrefixContainer().all(): |
| 316 | intfAddr = netaddr.IPNetwork(address).ip |
| 317 | |
| 318 | # If interface IP doesn't belong to this prefix, skip |
| 319 | if intfAddr not in netaddr.IPNetwork(prefix.subnet): |
| 320 | continue |
| 321 | |
| 322 | # If prefix is a parent prefix (parent prefix won't config domain name) |
| 323 | # skip to add in interface_subnets |
| 324 | if not prefix.data.description: |
| 325 | continue |
| 326 | |
| 327 | ret["interface_subnets"].setdefault(intfName, list()) |
| 328 | |
| 329 | if prefix.subnet not in ret["interface_subnets"][intfName]: |
| 330 | ret["interface_subnets"][intfName].append(prefix.subnet) |
| 331 | for neighbor in prefix.neighbor: |
| 332 | if neighbor.subnet not in ret["interface_subnets"][intfName]: |
| 333 | ret["interface_subnets"][intfName].append(neighbor.subnet) |
| 334 | |
| 335 | for prefix in PrefixContainer().all(): |
| 336 | |
| 337 | if "fab" in prefix.data.description: |
| 338 | ret["ue_routing"].setdefault("src_subnets", []) |
| 339 | ret["ue_routing"]["src_subnets"].append(prefix.data.prefix) |
| 340 | |
| 341 | if ( |
| 342 | not ret["ue_routing"].get("snat_addr") |
| 343 | and "fab" in prefix.data.description |
| 344 | ): |
| 345 | for interface in self.interfaces.values(): |
| 346 | for address in interface["addresses"]: |
| 347 | if address in netaddr.IPSet([prefix.subnet]): |
| 348 | ret["ue_routing"]["snat_addr"] = str( |
| 349 | netaddr.IPNetwork(address).ip |
| 350 | ) |
| 351 | break |
| 352 | |
| 353 | return ret |
| 354 | |
| 355 | def generate_extra_config(self): |
| 356 | """ |
| 357 | Generate the extra configs which need in management server configuration |
| 358 | This function should only be called when the device role is "Router" |
| 359 | """ |
| 360 | |
| 361 | if self.extra_config: |
| 362 | return self.extra_config |
| 363 | |
| 364 | primary_ip = self.data.primary_ip.address if self.data.primary_ip else None |
| 365 | |
| 366 | service_names = list(map(lambda x: x.name, self.services)) |
| 367 | |
| 368 | if "dns" in service_names: |
| 369 | unbound_listen_ips = [] |
| 370 | unbound_allow_ips = [] |
| 371 | |
| 372 | for interface in self.interfaces.values(): |
| 373 | if not interface["isPrimary"] and not interface["mgmtOnly"]: |
| 374 | for address in interface["addresses"]: |
| 375 | unbound_listen_ips.append(address) |
| 376 | |
| 377 | for prefix in PrefixContainer().all(): |
| 378 | if prefix.data.description: |
| 379 | unbound_allow_ips.append(prefix.data.prefix) |
| 380 | |
| 381 | if unbound_listen_ips: |
| 382 | self.extra_config["unbound_listen_ips"] = unbound_listen_ips |
| 383 | |
| 384 | if unbound_allow_ips: |
| 385 | self.extra_config["unbound_allow_ips"] = unbound_allow_ips |
| 386 | |
| 387 | if "ntp" in service_names: |
| 388 | ntp_client_allow = [] |
| 389 | |
| 390 | for prefix in PrefixContainer().all(): |
| 391 | if prefix.data.description: |
| 392 | ntp_client_allow.append(prefix.data.prefix) |
| 393 | |
| 394 | if ntp_client_allow: |
| 395 | self.extra_config["ntp_client_allow"] = ntp_client_allow |
| 396 | |
| 397 | return self.extra_config |
| 398 | |
| 399 | |
| 400 | class Device(AssignedObject): |
| 401 | """ |
| 402 | Wraps a single Netbox device |
| 403 | Also caches all known devices in a class variable (devs) |
| 404 | """ |
| 405 | |
| 406 | def __init__(self, data): |
| 407 | |
| 408 | super().__init__(data) |
| 409 | DeviceContainer().add(self.id, self) |
| 410 | |
| 411 | @property |
| 412 | def type(self): |
| 413 | return "Device" |
| 414 | |
| 415 | def get_interfaces(self): |
| 416 | if not self.interfaces: |
| 417 | self.interfaces = self.nbapi.dcim.interfaces.filter(device_id=self.id) |
| 418 | |
| 419 | return self.interfaces |
| 420 | |
| 421 | |
| 422 | class VirtualMachine(AssignedObject): |
| 423 | """ |
| 424 | VM equivalent of Device |
| 425 | """ |
| 426 | |
| 427 | def __init__(self, data): |
| 428 | |
| 429 | super().__init__(data) |
| 430 | VirtualMachineContainer().add(self.id, self) |
| 431 | |
| 432 | @property |
| 433 | def type(self): |
| 434 | return "VirtualMachine" |
| 435 | |
| 436 | def get_interfaces(self): |
| 437 | if not self.interfaces: |
| 438 | self.interfaces = self.nbapi.virtualization.interfaces.filter( |
| 439 | virtual_machine_id=self.id |
| 440 | ) |
| 441 | |
| 442 | return self.interfaces |