Repository: nautobot/nautobot

Issue:
Prefix related IPs wrong in /31 subnets
<!--
    NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.

    This form is only for reporting reproducible bugs. If you need assistance
    with Nautobot installation, or if you have a general question, please start a
    discussion instead: https://github.com/nautobot/nautobot/discussions

    Please describe the environment in which you are running Nautobot. Be sure
    that you are running an unmodified instance of the latest stable release
    before submitting a bug report, and that any plugins have been disabled.
-->
### Environment
* Python version: 3.6
* Nautobot version: 1.0.1

<!--
    Describe in detail the exact steps that someone else can take to reproduce
    this bug using the current stable release of Nautobot. Begin with the
    creation of any necessary database objects and call out every operation
    being performed explicitly. If reporting a bug in the REST API, be sure to
    reconstruct the raw HTTP request(s) being made: Don't rely on a client
    library such as pynautobot.
-->

When creating a small prefix say /31 and adding 2 IPs to that prefix the first IP is associated with the prefix but the second is not.  This might happen with larger prefixes but it is easily noticeable with smaller subnets.

### Steps to Reproduce
1. Create a prefix `192.168.0.62/31`
2. Create an IP `192.168.0.62/31`
3. Create an IP `192.168.0.63/31`

<!-- What did you expect to happen? -->
### Expected Behavior
The prefix should show 2 IPs.  If you find the first IP `192.168.0.62/31` it should show `192.168.0.63/31` as a related IP.

<!-- What happened instead? -->
### Observed Behavior
The prefix shows 1 IP.  `192.168.0.62/31` shows no related IPs but `192.168.0.63/31` does show `192.168.0.62/31` as a related IP.


Discussion:
@retnuh I believe I have captured the behavior you observed, if not feel free to correct me.
FYI /30 seems to work just fine
Yes that's correct; also the two IP's were created at the same time using the `ipam_prefixes_available-ips_create` endpoint.
FYI, The issue is present in the sandbox for a quick check 

Prefix `10.0.192.0/31`> https://demo.nautobot.com/ipam/prefixes/0e9b485f-5351-4502-8321-16c921cdee70/ip-addresses/
Should include both `10.0.192.0` and `10.0.192.1` but it's showing only the first one
https://demo.nautobot.com/ipam/ip-addresses/05b264b6-222e-42e9-bbbd-343be943f164/
https://demo.nautobot.com/ipam/ip-addresses/ef60f4a7-ab89-42e1-bae9-d81ed785e0b7/

<img width="1230" alt="Screen Shot 2021-05-21 at 12 44 44 PM" src="https://user-images.githubusercontent.com/304126/119171260-54df8d00-ba32-11eb-866b-90432c128fb7.png">


Resolving patch:
diff --git a/nautobot/ipam/migrations/0004_fixup_p2p_broadcast.py b/nautobot/ipam/migrations/0004_fixup_p2p_broadcast.py
new file mode 100644
--- /dev/null
+++ b/nautobot/ipam/migrations/0004_fixup_p2p_broadcast.py
@@ -0,0 +1,48 @@
+from django.db import migrations
+
+import netaddr
+
+
+def fixup_p2p_broadcast(apps, schema_editor):
+    """Correct the "broadcast" field for /31 and /127, as per https://github.com/nautobot/nautobot/pull/509."""
+    Aggregate = apps.get_model("ipam", "Aggregate")
+    Prefix = apps.get_model("ipam", "Prefix")
+    IPAddress = apps.get_model("ipam", "IPAddress")
+
+    # If the above models were our actual fully-implemented IPAM model classes, we could simply
+    # call model.prefix = model.prefix to force recalculation of the broadcast address,
+    # but the models we have access to here do not have any custom methods available.
+    # So we have to fixup the broadcast addresses ourselves.
+
+    for prefixlen in (31, 127):
+        for aggregate in Aggregate.objects.filter(prefix_length=prefixlen):
+            network = netaddr.IPNetwork(f"{aggregate.network}/{aggregate.prefix_length}")
+            # Last address in the network is our "broadcast" address.
+            aggregate.broadcast = network[-1]
+            aggregate.save()
+
+        for prefix in Prefix.objects.filter(prefix_length=prefixlen):
+            network = netaddr.IPNetwork(f"{prefix.network}/{prefix.prefix_length}")
+            # Last address in the network is our "broadcast" address.
+            prefix.broadcast = network[-1]
+            prefix.save()
+
+        for ipaddress in IPAddress.objects.filter(prefix_length=prefixlen):
+            network = netaddr.IPNetwork(f"{ipaddress.host}/{ipaddress.prefix_length}")
+            # Last address in the network is our "broadcast" address.
+            ipaddress.broadcast = network[-1]
+            ipaddress.save()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("ipam", "0003_remove_max_length"),
+    ]
+
+    operations = [
+        migrations.RunPython(
+            code=fixup_p2p_broadcast,
+            reverse_code=migrations.RunPython.noop,
+        ),
+    ]
diff --git a/nautobot/ipam/models.py b/nautobot/ipam/models.py
--- a/nautobot/ipam/models.py
+++ b/nautobot/ipam/models.py
@@ -265,9 +265,15 @@ def _deconstruct_prefix(self, pre):
         if pre:
             if isinstance(pre, str):
                 pre = netaddr.IPNetwork(pre)
-            # if |address.prefixlen| is 32 (ip4) or 128 (ip6)
-            # then |address.broadcast| is None
-            broadcast = pre.broadcast if pre.broadcast else pre.network
+            # Note that our "broadcast" field is actually the last IP address in this prefix.
+            # This is different from the more accurate technical meaning of a network's broadcast address in 2 cases:
+            # 1. For a point-to-point prefix (IPv4 /31 or IPv6 /127), there are two addresses in the prefix,
+            #    and neither one is considered a broadcast address. We store the second address as our "broadcast".
+            # 2. For a host prefix (IPv6 /32 or IPv6 /128) there's only one address in the prefix.
+            #    We store this address as both the network and the "broadcast".
+            # This variance is intentional in both cases as we use the "broadcast" primarily for filtering and grouping
+            # of addresses and prefixes, not for packet forwarding. :-)
+            broadcast = pre.broadcast if pre.broadcast else pre[-1]
             self.network = str(pre.network)
             self.broadcast = str(broadcast)
             self.prefix_length = pre.prefixlen
@@ -516,9 +522,15 @@ def _deconstruct_prefix(self, pre):
         if pre:
             if isinstance(pre, str):
                 pre = netaddr.IPNetwork(pre)
-            # if |prefix.prefixlen| is 32 (ip4) or 128 (ip6)
-            # then |prefix.broadcast| is None
-            broadcast = pre.broadcast if pre.broadcast else pre.network
+            # Note that our "broadcast" field is actually the last IP address in this prefix.
+            # This is different from the more accurate technical meaning of a network's broadcast address in 2 cases:
+            # 1. For a point-to-point prefix (IPv4 /31 or IPv6 /127), there are two addresses in the prefix,
+            #    and neither one is considered a broadcast address. We store the second address as our "broadcast".
+            # 2. For a host prefix (IPv6 /32 or IPv6 /128) there's only one address in the prefix.
+            #    We store this address as both the network and the "broadcast".
+            # This variance is intentional in both cases as we use the "broadcast" primarily for filtering and grouping
+            # of addresses and prefixes, not for packet forwarding. :-)
+            broadcast = pre.broadcast if pre.broadcast else pre[-1]
             self.network = str(pre.network)
             self.broadcast = str(broadcast)
             self.prefix_length = pre.prefixlen
@@ -826,9 +838,15 @@ def _deconstruct_address(self, address):
         if address:
             if isinstance(address, str):
                 address = netaddr.IPNetwork(address)
-            # if |address.prefixlen| is 32 (ip4) or 128 (ip6)
-            # then |address.broadcast| is None
-            broadcast = address.broadcast if address.broadcast else address.network
+            # Note that our "broadcast" field is actually the last IP address in this network.
+            # This is different from the more accurate technical meaning of a network's broadcast address in 2 cases:
+            # 1. For a point-to-point address (IPv4 /31 or IPv6 /127), there are two addresses in the network,
+            #    and neither one is considered a broadcast address. We store the second address as our "broadcast".
+            # 2. For a host prefix (IPv6 /32 or IPv6 /128) there's only one address in the network.
+            #    We store this address as both the host and the "broadcast".
+            # This variance is intentional in both cases as we use the "broadcast" primarily for filtering and grouping
+            # of addresses and prefixes, not for packet forwarding. :-)
+            broadcast = address.broadcast if address.broadcast else address[-1]
             self.host = str(address.ip)
             self.broadcast = str(broadcast)
             self.prefix_length = address.prefixlen
diff --git a/nautobot/ipam/querysets.py b/nautobot/ipam/querysets.py
--- a/nautobot/ipam/querysets.py
+++ b/nautobot/ipam/querysets.py
@@ -22,48 +22,54 @@
 
 class NetworkQuerySet(QuerySet):
     @staticmethod
-    def _get_broadcast(prefix):
-        return prefix.broadcast if prefix.broadcast else prefix.network
+    def _get_last_ip(prefix):
+        """
+        Get the last IP address in the given prefix.
+
+        This is distinct from prefix.broadcast in the case of point-to-point or host network prefixes
+        (neither of which technically have a "broadcast" address).
+        """
+        return prefix.broadcast if prefix.broadcast else prefix[-1]
 
     def net_equals(self, prefix):
         prefix = netaddr.IPNetwork(prefix)
-        broadcast = self._get_broadcast(prefix)
-        return self.filter(prefix_length=prefix.prefixlen, network=prefix.network, broadcast=broadcast)
+        last_ip = self._get_last_ip(prefix)
+        return self.filter(prefix_length=prefix.prefixlen, network=prefix.network, broadcast=last_ip)
 
     def net_contained(self, prefix):
         prefix = netaddr.IPNetwork(prefix)
-        broadcast = self._get_broadcast(prefix)
+        last_ip = self._get_last_ip(prefix)
         return self.filter(
             prefix_length__gt=prefix.prefixlen,
             network__gte=prefix.network,
-            broadcast__lte=broadcast,
+            broadcast__lte=last_ip,
         )
 
     def net_contained_or_equal(self, prefix):
         prefix = netaddr.IPNetwork(prefix)
-        broadcast = self._get_broadcast(prefix)
+        last_ip = self._get_last_ip(prefix)
         return self.filter(
             prefix_length__gte=prefix.prefixlen,
             network__gte=prefix.network,
-            broadcast__lte=broadcast,
+            broadcast__lte=last_ip,
         )
 
     def net_contains(self, prefix):
         prefix = netaddr.IPNetwork(prefix)
-        broadcast = self._get_broadcast(prefix)
+        last_ip = self._get_last_ip(prefix)
         return self.filter(
             prefix_length__lt=prefix.prefixlen,
             network__lte=prefix.network,
-            broadcast__gte=broadcast,
+            broadcast__gte=last_ip,
         )
 
     def net_contains_or_equals(self, prefix):
         prefix = netaddr.IPNetwork(prefix)
-        broadcast = self._get_broadcast(prefix)
+        last_ip = self._get_last_ip(prefix)
         return self.filter(
             prefix_length__lte=prefix.prefixlen,
             network__lte=prefix.network,
-            broadcast__gte=broadcast,
+            broadcast__gte=last_ip,
         )
 
     def get(self, *args, prefix=None, **kwargs):
@@ -74,10 +80,10 @@ def get(self, *args, prefix=None, **kwargs):
             _prefix = netaddr.IPNetwork(prefix)
             if str(_prefix) != prefix:
                 raise self.model.DoesNotExist()
-            broadcast = self._get_broadcast(_prefix)
+            last_ip = self._get_last_ip(_prefix)
             kwargs["prefix_length"] = _prefix.prefixlen
             kwargs["network"] = _prefix.ip  # Query based on the input, not the true network address
-            kwargs["broadcast"] = broadcast
+            kwargs["broadcast"] = last_ip
         return super().get(*args, **kwargs)
 
     def filter(self, *args, prefix=None, **kwargs):
@@ -86,10 +92,10 @@ def filter(self, *args, prefix=None, **kwargs):
         """
         if prefix:
             _prefix = netaddr.IPNetwork(prefix)
-            broadcast = self._get_broadcast(_prefix)
+            last_ip = self._get_last_ip(_prefix)
             kwargs["prefix_length"] = _prefix.prefixlen
             kwargs["network"] = _prefix.ip  # Query based on the input, not the true network address
-            kwargs["broadcast"] = broadcast
+            kwargs["broadcast"] = last_ip
         return super().filter(*args, **kwargs)
 
 
@@ -177,8 +183,14 @@ class IPAddressQuerySet(RestrictedQuerySet):
     RE_HEXTET = re.compile("^[a-f0-9]{4}$")
 
     @staticmethod
-    def _get_broadcast(network):
-        return network.broadcast if network.broadcast else network.network
+    def _get_last_ip(network):
+        """
+        Get the last IP address in the given network.
+
+        This is distinct from network.broadcast in the case of point-to-point or host networks
+        (neither of which technically have a "broadcast" address).
+        """
+        return network.broadcast if network.broadcast else network[-1]
 
     def get_queryset(self):
         """
@@ -198,11 +210,11 @@ def string_search(self, search):
             return self.none()
 
         network = self._parse_as_network_string(search)
-        broadcast = self._get_broadcast(network)
+        last_ip = self._get_last_ip(network)
         return self.filter(
             Q(dns_name__icontains=search)
             | Q(description__icontains=search)
-            | Q(host__lte=broadcast, host__gte=network.network)  # same as `net_host_contained()`
+            | Q(host__lte=last_ip, host__gte=network.network)  # same as `net_host_contained()`
         )
 
     def _parse_as_network_string(self, search):
@@ -293,9 +305,9 @@ def net_host_contained(self, network):
         # consider only host ip address when
         # filtering for membership in |network|
         network = netaddr.IPNetwork(network)
-        broadcast = self._get_broadcast(network)
+        last_ip = self._get_last_ip(network)
         return self.filter(
-            host__lte=broadcast,
+            host__lte=last_ip,
             host__gte=network.network,
         )
 
@@ -313,10 +325,10 @@ def get(self, *args, address=None, **kwargs):
         """
         if address:
             address = netaddr.IPNetwork(address)
-            broadcast = self._get_broadcast(address)
+            last_ip = self._get_last_ip(address)
             kwargs["prefix_length"] = address.prefixlen
             kwargs["host"] = address.ip
-            kwargs["broadcast"] = broadcast
+            kwargs["broadcast"] = last_ip
         return super().get(*args, **kwargs)
 
     def filter(self, *args, address=None, **kwargs):
@@ -325,8 +337,8 @@ def filter(self, *args, address=None, **kwargs):
         """
         if address:
             address = netaddr.IPNetwork(address)
-            broadcast = self._get_broadcast(address)
+            last_ip = self._get_last_ip(address)
             kwargs["prefix_length"] = address.prefixlen
             kwargs["host"] = address.ip
-            kwargs["broadcast"] = broadcast
+            kwargs["broadcast"] = last_ip
         return super().filter(*args, **kwargs)


Test patch:
diff --git a/nautobot/ipam/tests/test_models.py b/nautobot/ipam/tests/test_models.py
--- a/nautobot/ipam/tests/test_models.py
+++ b/nautobot/ipam/tests/test_models.py
@@ -198,6 +198,18 @@ def test_get_child_ips(self):
         # VRF container is limited to its own VRF
         self.assertSetEqual(child_ip_pks, {ips[1].pk})
 
+        # Make sure /31 is handled correctly
+        parent_prefix_31 = Prefix.objects.create(
+            prefix=netaddr.IPNetwork("10.0.4.0/31"), status=Prefix.STATUS_CONTAINER
+        )
+        ips_31 = (
+            IPAddress.objects.create(address=netaddr.IPNetwork("10.0.4.0/31"), vrf=None),
+            IPAddress.objects.create(address=netaddr.IPNetwork("10.0.4.1/31"), vrf=None),
+        )
+
+        child_ip_pks = {p.pk for p in parent_prefix_31.get_child_ips()}
+        self.assertSetEqual(child_ip_pks, {ips_31[0].pk, ips_31[1].pk})
+
     def test_get_available_prefixes(self):
 
         prefixes = Prefix.objects.bulk_create(
diff --git a/nautobot/ipam/tests/test_querysets.py b/nautobot/ipam/tests/test_querysets.py
--- a/nautobot/ipam/tests/test_querysets.py
+++ b/nautobot/ipam/tests/test_querysets.py
@@ -95,6 +95,7 @@ def test_ip_family(self):
     def test_net_host_contained(self):
         self.assertEqual(self.queryset.net_host_contained(netaddr.IPNetwork("10.0.0.0/24")).count(), 5)
         self.assertEqual(self.queryset.net_host_contained(netaddr.IPNetwork("10.0.0.0/30")).count(), 4)
+        self.assertEqual(self.queryset.net_host_contained(netaddr.IPNetwork("10.0.0.0/31")).count(), 2)
         self.assertEqual(self.queryset.net_host_contained(netaddr.IPNetwork("10.0.10.0/24")).count(), 0)
 
     def test_net_in(self):