class Discovery(CleanSave, ViewModel): """A `Discovery` object represents the combined data for a network entity that MAAS believes has been discovered. Note that this class is backed by the `maasserver_discovery` view. Any updates to this model must be reflected in `maasserver/dbviews.py` under the `maasserver_discovery` view. """ class Meta(DefaultViewMeta): # When managed is False, Django will not create a migration for this # model class. This is required for model classes based on views. verbose_name = "Discovery" verbose_name_plural = "Discoveries" def __str__(self): return "<Discovery: %s at %s via %s>" % ( self.ip, self.last_seen, self.observer_interface.get_log_string(), ) discovery_id = CharField(max_length=256, editable=False, null=True, blank=False, unique=True) neighbour = ForeignKey( "Neighbour", unique=False, blank=False, null=False, editable=False, on_delete=DO_NOTHING, ) # Observed IP address. ip = MAASIPAddressField( unique=False, null=True, editable=False, blank=True, default=None, verbose_name="IP", ) mac_address = MACAddressField(unique=False, null=True, blank=True, editable=False) first_seen = DateTimeField(editable=False) last_seen = DateTimeField(editable=False) mdns = ForeignKey( "MDNS", unique=False, blank=True, null=True, editable=False, on_delete=DO_NOTHING, ) # Hostname observed from mDNS-browse. hostname = CharField(max_length=256, editable=False, null=True, blank=False, unique=False) observer = ForeignKey( "Node", unique=False, blank=False, null=False, editable=False, on_delete=DO_NOTHING, ) observer_system_id = CharField(max_length=41, unique=False, editable=False) # The hostname of the node that made the discovery. observer_hostname = DomainNameField(max_length=256, editable=False, null=True, blank=False, unique=False) # Rack interface the discovery was observed on. observer_interface = ForeignKey( "Interface", unique=False, blank=False, null=False, editable=False, on_delete=DO_NOTHING, ) observer_interface_name = CharField(blank=False, editable=False, max_length=255) fabric = ForeignKey( "Fabric", unique=False, blank=False, null=False, editable=False, on_delete=DO_NOTHING, ) fabric_name = CharField(max_length=256, editable=False, null=True, blank=True, unique=False) vlan = ForeignKey( "VLAN", unique=False, blank=False, null=False, editable=False, on_delete=DO_NOTHING, ) vid = IntegerField(null=True, blank=True) # These will only be non-NULL if we found a related Subnet. subnet = ForeignKey( "Subnet", unique=False, blank=True, null=True, editable=False, on_delete=DO_NOTHING, ) subnet_cidr = CIDRField(blank=True, unique=False, editable=False, null=True) is_external_dhcp = NullBooleanField(blank=True, unique=False, editable=False, null=True) objects = DiscoveryManager() @property def mac_organization(self): return get_mac_organization(str(self.mac_address))
class Subnet(CleanSave, TimestampedModel): def __init__(self, *args, **kwargs): assert "space" not in kwargs, "Subnets can no longer be in spaces." super().__init__(*args, **kwargs) objects = SubnetManager() name = CharField( blank=False, editable=True, max_length=255, validators=[SUBNET_NAME_VALIDATOR], help_text="Identifying name for this subnet.", ) description = TextField(null=False, blank=True) vlan = ForeignKey("VLAN", editable=True, blank=False, null=False, on_delete=PROTECT) # XXX:fabric: unique constraint should be relaxed once proper support for # fabrics is implemented. The CIDR must be unique within a Fabric, not # globally unique. cidr = CIDRField(blank=False, unique=True, editable=True, null=False) rdns_mode = IntegerField(choices=RDNS_MODE_CHOICES, editable=True, default=RDNS_MODE.DEFAULT) gateway_ip = GenericIPAddressField(blank=True, editable=True, null=True) dns_servers = ArrayField(TextField(), blank=True, editable=True, null=True, default=list) allow_dns = BooleanField(editable=True, blank=False, null=False, default=True) allow_proxy = BooleanField(editable=True, blank=False, null=False, default=True) active_discovery = BooleanField(editable=True, blank=False, null=False, default=False) managed = BooleanField(editable=True, blank=False, null=False, default=True) @property def label(self): """Returns a human-friendly label for this subnet.""" cidr = str(self.cidr) # Note: there is a not-NULL check for the 'name' field, so this only # applies to unsaved objects. if self.name is None or self.name == "": return cidr if cidr not in self.name: return "%s (%s)" % (self.name, self.cidr) else: return self.name @property def space(self): """Backward compatibility shim to get the space for this subnet.""" return self.vlan.space def get_ipnetwork(self) -> IPNetwork: return IPNetwork(self.cidr) def get_ip_version(self) -> int: return self.get_ipnetwork().version def update_cidr(self, cidr): cidr = str(cidr) # If the old name had the CIDR embedded in it, update that first. if self.name: self.name = self.name.replace(str(self.cidr), cidr) else: self.name = cidr self.cidr = cidr def __str__(self): return "%s:%s(vid=%s)" % (self.name, self.cidr, self.vlan.vid) def validate_gateway_ip(self): if self.gateway_ip is None or self.gateway_ip == "": return gateway_addr = IPAddress(self.gateway_ip) network = self.get_ipnetwork() if gateway_addr in network: # If the gateway is in the network, it is fine. return elif network.version == 6 and gateway_addr.is_link_local(): # If this is an IPv6 network and the gateway is in the link-local # network (fe80::/64 -- required to be configured by the spec), # then it is also valid. return else: # The gateway is not valid for the network. message = "Gateway IP must be within CIDR range." raise ValidationError({"gateway_ip": [message]}) def clean_fields(self, *args, **kwargs): # XXX mpontillo 2016-03-16: this function exists due to bug #1557767. # This workaround exists to prevent potential unintended consequences # of making the name optional. if (self.name is None or self.name == "") and self.cidr is not None: self.name = str(self.cidr) super().clean_fields(*args, **kwargs) def clean(self, *args, **kwargs): self.validate_gateway_ip() def delete(self, *args, **kwargs): # Check if DHCP is enabled on the VLAN this subnet is attached to. if self.vlan.dhcp_on and self.get_dynamic_ranges().exists(): raise ValidationError( "Cannot delete a subnet that is actively servicing a dynamic " "IP range. (Delete the dynamic range or disable DHCP first.)") super().delete(*args, **kwargs) def get_allocated_ips(self): """Get all the IPs for the given subnets Any StaticIPAddress record that has a non-emtpy ip is considered to be allocated. It returns a generator producing a 2-tuple with the subnet and a list of IP tuples An IP tuple consist of the IP as a string and its allocation type. The result can be cached by calling cache_allocated_ips(). """ ips = getattr(self, "_cached_allocated_ips", None) if ips is None: [(_, ips)] = list(get_allocated_ips([self])) return ips def cache_allocated_ips(self, ips): """Cache the results of get_allocated_ips(). This is to be used similar to how prefetching objects on queryset works. """ self._cached_allocated_ips = ips def _get_ranges_for_allocated_ips(self, ipnetwork: IPNetwork, ignore_discovered_ips: bool) -> set: """Returns a set of MAASIPRange objects created from the set of allocated StaticIPAddress objects. """ ranges = set() # We work with tuple rather than real model objects, since a # subnet may many IPs and creating a model object for each IP is # slow. ips = self.get_allocated_ips() for ip, alloc_type in ips: if ip and not (ignore_discovered_ips and (alloc_type == IPADDRESS_TYPE.DISCOVERED)): ip = IPAddress(ip) if ip in ipnetwork: ranges.add(make_iprange(ip, purpose="assigned-ip")) return ranges def get_ipranges_in_use( self, exclude_addresses: IPAddressExcludeList = None, ranges_only: bool = False, include_reserved: bool = True, with_neighbours: bool = False, ignore_discovered_ips: bool = False, exclude_ip_ranges: list = None, cached_staticroutes: list = None, ) -> MAASIPSet: """Returns a `MAASIPSet` of `MAASIPRange` objects which are currently in use on this `Subnet`. :param exclude_addresses: Additional addresses to consider "in use". :param ignore_discovered_ips: DISCOVERED addresses are not "in use". :param ranges_only: if True, filters out gateway IPs, static routes, DNS servers, and `exclude_addresses`. :param with_neighbours: If True, includes addresses learned from neighbour observation. """ if exclude_addresses is None: exclude_addresses = [] ranges = set() network = self.get_ipnetwork() if network.version == 6: # For most IPv6 networks, automatically reserve the range: # ::1 - ::ffff:ffff # We expect the administrator will be using ::1 through ::ffff. # We plan to reserve ::1:0 through ::ffff:ffff for use by MAAS, # so that we can allocate addresses in the form: # ::<node>:<child> # For now, just make sure IPv6 addresses are allocated from # *outside* both ranges, so that they won't conflict with addresses # reserved from this scheme in the future. first = str(IPAddress(network.first)) first_plus_one = str(IPAddress(network.first + 1)) second = str(IPAddress(network.first + 0xFFFFFFFF)) if network.prefixlen == 64: ranges |= { make_iprange(first_plus_one, second, purpose="reserved") } # Reserve the subnet router anycast address, except for /127 and # /128 networks. (See RFC 6164, and RFC 4291 section 2.6.1.) if network.prefixlen < 127: ranges |= { make_iprange(first, first, purpose="rfc-4291-2.6.1") } if not ranges_only: if (self.gateway_ip is not None and self.gateway_ip != "" and self.gateway_ip in network): ranges |= {make_iprange(self.gateway_ip, purpose="gateway-ip")} if self.dns_servers is not None: ranges |= set( make_iprange(server, purpose="dns-server") for server in self.dns_servers if server in network) if cached_staticroutes is not None: static_routes = [ static_route for static_route in cached_staticroutes if static_route.source == self ] else: static_routes = StaticRoute.objects.filter(source=self) for static_route in static_routes: ranges |= { make_iprange(static_route.gateway_ip, purpose="gateway-ip") } ranges |= self._get_ranges_for_allocated_ips( network, ignore_discovered_ips) ranges |= set( make_iprange(address, purpose="excluded") for address in exclude_addresses if address in network) if include_reserved: ranges |= self.get_reserved_maasipset( exclude_ip_ranges=exclude_ip_ranges) ranges |= self.get_dynamic_maasipset( exclude_ip_ranges=exclude_ip_ranges) if with_neighbours: ranges |= self.get_maasipset_for_neighbours() return MAASIPSet(ranges) def get_ipranges_available_for_reserved_range( self, exclude_ip_ranges: list = None): return self.get_ipranges_not_in_use( ranges_only=True, exclude_ip_ranges=exclude_ip_ranges) def get_ipranges_available_for_dynamic_range(self, exclude_ip_ranges: list = None ): return self.get_ipranges_not_in_use( ranges_only=False, ignore_discovered_ips=True, exclude_ip_ranges=exclude_ip_ranges, ) def get_ipranges_not_in_use( self, exclude_addresses: IPAddressExcludeList = None, ranges_only: bool = False, ignore_discovered_ips: bool = False, with_neighbours: bool = False, exclude_ip_ranges: list = None, ) -> MAASIPSet: """Returns a `MAASIPSet` of ranges which are currently free on this `Subnet`. :param ranges_only: if True, filters out gateway IPs, static routes, DNS servers, and `exclude_addresses`. :param exclude_addresses: An iterable of addresses not to use. :param ignore_discovered_ips: DISCOVERED addresses are not "in use". :param with_neighbours: If True, includes addresses learned from neighbour observation. """ if exclude_addresses is None: exclude_addresses = [] in_use = self.get_ipranges_in_use( exclude_addresses=exclude_addresses, ranges_only=ranges_only, with_neighbours=with_neighbours, ignore_discovered_ips=ignore_discovered_ips, exclude_ip_ranges=exclude_ip_ranges, ) if self.managed or ranges_only: not_in_use = in_use.get_unused_ranges(self.get_ipnetwork()) else: # The end result we want is a list of unused IP addresses *within* # reserved ranges. To get that result, we first need the full list # of unused IP addresses on the subnet. This is better illustrated # visually below. # # Legend: # X: in-use IP addresses # R: reserved range # Rx: reserved range (with allocated, in-use IP address) # # +----+----+----+----+----+----+ # IP address: | 1 | 2 | 3 | 4 | 5 | 6 | # +----+----+----+----+----+----+ # Usages: | X | | R | Rx | | X | # +----+----+----+----+----+----+ # # We need a set that just contains `3` in this case. To get there, # first calculate the set of all unused addresses on the subnet, # then intersect that set with set of in-use addresses *excluding* # the reserved range, then calculate which addresses within *that* # set are unused: # +----+----+----+----+----+----+ # IP address: | 1 | 2 | 3 | 4 | 5 | 6 | # +----+----+----+----+----+----+ # unused: | | U | | | U | | # +----+----+----+----+----+----+ # unmanaged_in_use: | u | | | u | | u | # +----+----+----+----+----+----+ # |= unmanaged: =============================== # +----+----+----+----+----+----+ # unmanaged_in_use: | u | U | | u | U | u | # +----+----+----+----+----+----+ # get_unused_ranges(): =============================== # +----+----+----+----+----+----+ # not_in_use: | | | n | | | | # +----+----+----+----+----+----+ unused = in_use.get_unused_ranges( self.get_ipnetwork(), purpose=MAASIPRANGE_TYPE.UNMANAGED) unmanaged_in_use = self.get_ipranges_in_use( exclude_addresses=exclude_addresses, ranges_only=ranges_only, include_reserved=False, with_neighbours=with_neighbours, ignore_discovered_ips=ignore_discovered_ips, exclude_ip_ranges=exclude_ip_ranges, ) unmanaged_in_use |= unused not_in_use = unmanaged_in_use.get_unused_ranges( self.get_ipnetwork(), purpose=MAASIPRANGE_TYPE.UNUSED) return not_in_use def get_maasipset_for_neighbours(self) -> MAASIPSet: """Return the observed neighbours in this subnet. :return: MAASIPSet of neighbours (with the "neighbour" purpose). """ # Circular imports. from maasserver.models import Discovery # Note: we only need unknown IP addresses here, because the known # IP addresses should already be covered by get_ipranges_in_use(). neighbours = Discovery.objects.filter(subnet=self).by_unknown_ip() neighbour_set = { make_iprange(neighbour.ip, purpose="neighbour") for neighbour in neighbours } return MAASIPSet(neighbour_set) def get_least_recently_seen_unknown_neighbour(self): """ Returns the least recently seen unknown neighbour or this subnet. Useful when allocating an IP address, to safeguard against assigning an address another host is still using. :return: a `maasserver.models.Discovery` object """ # Circular imports. from maasserver.models import Discovery # Note: for the purposes of this function, being in part of a "used" # range (such as a router IP address or reserved range) makes it # "known". So we need to avoid those here in order to avoid stepping # on network infrastructure, reserved ranges, etc. unused = self.get_ipranges_not_in_use(ignore_discovered_ips=True) least_recent_neighbours = (Discovery.objects.filter( subnet=self).by_unknown_ip().order_by("last_seen")) for neighbor in least_recent_neighbours: if neighbor.ip in unused: return neighbor return None def get_iprange_usage(self, with_neighbours=False, cached_staticroutes=None) -> MAASIPSet: """Returns both the reserved and unreserved IP ranges in this Subnet. (This prevents a potential race condition that could occur if an IP address is allocated or deallocated between calls.) :returns: A tuple indicating the (reserved, unreserved) ranges. """ reserved_ranges = self.get_ipranges_in_use( cached_staticroutes=cached_staticroutes) if with_neighbours is True: reserved_ranges |= self.get_maasipset_for_neighbours() return reserved_ranges.get_full_range(self.get_ipnetwork()) def get_next_ip_for_allocation( self, exclude_addresses: Optional[Iterable] = None, avoid_observed_neighbours: bool = True, ): """Heuristic to return the "best" address from this subnet to use next. :param exclude_addresses: Optional list of addresses to exclude. :param avoid_observed_neighbours: Optional parameter to specify if known observed neighbours should be avoided. This parameter is not intended to be specified by a caller in production code; it is used internally to recursively call this method if the first allocation attempt fails. """ if exclude_addresses is None: exclude_addresses = [] free_ranges = self.get_ipranges_not_in_use( exclude_addresses=exclude_addresses, with_neighbours=avoid_observed_neighbours, ) if len(free_ranges) == 0 and avoid_observed_neighbours is True: # Try again recursively, but this time consider neighbours to be # "free" IP addresses. (We'll pick the least recently seen IP.) return self.get_next_ip_for_allocation( exclude_addresses, avoid_observed_neighbours=False) elif len(free_ranges) == 0: raise StaticIPAddressExhaustion( "No more IPs available in subnet: %s." % self.cidr) # The first time through this function, we aren't trying to avoid # observed neighbours. In fact, `free_ranges` only contains completely # unused ranges. So we don't need to check for the least recently seen # neighbour on the first pass. if avoid_observed_neighbours is False: # We tried considering neighbours as "in-use" addresses, but the # subnet is still full. So make an educated guess about which IP # address is least likely to be in-use. discovery = self.get_least_recently_seen_unknown_neighbour() if discovery is not None: maaslog.warning( "Next IP address to allocate from '%s' has been observed " "previously: %s was last claimed by %s via %s at %s." % ( self.label, discovery.ip, discovery.mac_address, discovery.observer_interface.get_log_string(), discovery.last_seen, )) return str(discovery.ip) # The purpose of this is to that we ensure we always get an IP address # from the *smallest* free contiguous range. This way, larger ranges # can be preserved in case they need to be used for applications # requiring them. If two ranges have the same number of IPs, choose the # lowest one. free_range = min(free_ranges, key=attrgetter("num_addresses", "first")) return str(IPAddress(free_range.first)) def render_json_for_related_ips(self, with_username=True, with_summary=True): """Render a representation of this subnet's related IP addresses, suitable for converting to JSON. Optionally exclude user and node information.""" ip_addresses = self.staticipaddress_set.all() if with_username: ip_addresses = ip_addresses.prefetch_related("user") if with_summary: ip_addresses = ip_addresses.prefetch_related( "interface_set", "interface_set__node", "interface_set__node__domain", "bmc_set", "bmc_set__node_set", "dnsresource_set", "dnsresource_set__domain", ) return sorted( [ ip.render_json(with_username=with_username, with_summary=with_summary) for ip in ip_addresses if ip.ip ], key=lambda json: IPAddress(json["ip"]), ) def get_dynamic_ranges(self): return self.iprange_set.filter(type=IPRANGE_TYPE.DYNAMIC) def get_reserved_ranges(self): return self.iprange_set.filter(type=IPRANGE_TYPE.RESERVED) def is_valid_static_ip(self, *args, **kwargs): """Validates that the requested IP address is acceptable for allocation in this `Subnet` (assuming it has not already been allocated). Returns `True` if the IP address is acceptable, and `False` if not. Does not consider whether or not the IP address is already allocated, only whether or not it is in the proper network and range. :return: bool """ try: self.validate_static_ip(*args, **kwargs) except MAASAPIException: return False return True def validate_static_ip(self, ip): """Validates that the requested IP address is acceptable for allocation in this `Subnet` (assuming it has not already been allocated). Raises `StaticIPAddressUnavailable` if the address is not acceptable. Does not consider whether or not the IP address is already allocated, only whether or not it is in the proper network and range. :raises StaticIPAddressUnavailable: If the IP address specified is not available for allocation. """ if ip not in self.get_ipnetwork(): raise StaticIPAddressOutOfRange( "%s is not within subnet CIDR: %s" % (ip, self.cidr)) for iprange in self.get_reserved_maasipset(): if ip in iprange: raise StaticIPAddressUnavailable( "%s is within the reserved range from %s to %s" % (ip, IPAddress(iprange.first), IPAddress(iprange.last))) for iprange in self.get_dynamic_maasipset(): if ip in iprange: raise StaticIPAddressUnavailable( "%s is within the dynamic range from %s to %s" % (ip, IPAddress(iprange.first), IPAddress(iprange.last))) def get_reserved_maasipset(self, exclude_ip_ranges: list = None): if exclude_ip_ranges is None: exclude_ip_ranges = [] reserved_ranges = MAASIPSet(iprange.get_MAASIPRange() for iprange in self.iprange_set.all() if iprange.type == IPRANGE_TYPE.RESERVED and iprange not in exclude_ip_ranges) return reserved_ranges def get_dynamic_maasipset(self, exclude_ip_ranges: list = None): if exclude_ip_ranges is None: exclude_ip_ranges = [] dynamic_ranges = MAASIPSet(iprange.get_MAASIPRange() for iprange in self.iprange_set.all() if iprange.type == IPRANGE_TYPE.DYNAMIC and iprange not in exclude_ip_ranges) return dynamic_ranges def get_dynamic_range_for_ip(self, ip): """Return `IPRange` for the provided `ip`.""" # XXX mpontillo 2016-01-21: for some reason this query doesn't work. # I tried it both like this, and with: # start_ip__gte=ip, and end_ip__lte=ip # return get_one(self.get_dynamic_ranges().extra( # where=["start_ip >= inet '%s'" % ip, # ... which sounds a lot like comment 15 in: # https://code.djangoproject.com/ticket/11442 for iprange in self.get_dynamic_ranges(): if ip in iprange.netaddr_iprange: return iprange return None def get_smallest_enclosing_sane_subnet(self): """Return the subnet that includes this subnet. It must also be at least big enough to be a parent in the RFC2317 world (/24 in IPv4, /124 in IPv6). If no such subnet exists, return None. """ find_rfc2137_parent_query = """ SELECT * FROM maasserver_subnet WHERE %s << cidr AND ( (family(cidr) = 6 and masklen(cidr) <= 124) OR (family(cidr) = 4 and masklen(cidr) <= 24)) ORDER BY masklen(cidr) DESC LIMIT 1 """ for s in Subnet.objects.raw(find_rfc2137_parent_query, (self.cidr, )): return s return None def update_allocation_notification(self): # Workaround for edge cases in Django. (See bug #1702527.) if self.id is None: return ident = "ip_exhaustion__subnet_%d" % self.id # Circular imports. from maasserver.models import Config, Notification threshold = Config.objects.get_config( "subnet_ip_exhaustion_threshold_count") notification = Notification.objects.filter(ident=ident).first() delete_notification = False if threshold > 0: full_iprange = self.get_iprange_usage() statistics = IPRangeStatistics(full_iprange) # Check if there are less available IPs in the subnet than the # warning threshold. meets_warning_threshold = statistics.num_available <= threshold # Check if the warning threshold is appropriate relative to the # size of the subnet. It's pointless to warn about address # exhaustion on a /30, for example: the admin already knows it's # small, so we would just be annoying them. subnet_is_reasonably_large_relative_to_threshold = ( threshold * 3 <= statistics.total_addresses) if (meets_warning_threshold and subnet_is_reasonably_large_relative_to_threshold): notification_text = ( "IP address exhaustion imminent on subnet: %s. " "There are %d free addresses out of %d " "(%s used).") % ( self.label, statistics.num_available, statistics.total_addresses, statistics.usage_percentage_string, ) if notification is None: Notification.objects.create_warning_for_admins( notification_text, ident=ident) else: # Note: This will update the notification, but will not # bring it back for those who have dismissed it. Maybe we # should consider creating a new notification if the # situation is now more severe, such as raise it to an # error if it's half remaining threshold. notification.message = notification_text notification.save() else: delete_notification = True else: delete_notification = True if notification is not None and delete_notification: notification.delete()
class CIDRTestModel(Model): cidr = CIDRField()