Jelajahi Sumber

feat(ipam): Classify prefixes by address space

Adds a read-only classification to prefixes, derived from the IANA
registries. It is computed from the prefix in Python, so custom
validators see unsaved edits, and a database trigger keeps an indexed
copy for filtering in the UI, REST API and GraphQL.

Fixes #21609
Martin Hauser 12 jam lalu
induk
melakukan
662eef7351

+ 21 - 0
docs/models/ipam/prefix.md

@@ -8,6 +8,27 @@ A prefix is an IPv4 or IPv6 network and mask expressed in CIDR notation (e.g. 19
 
 The IPv4 or IPv6 network this prefix represents.
 
+### Classification
+
+Classification describes the address space covered by a prefix and is read-only. It does not indicate ownership, route advertisement, or reachability.
+
+The most specific containing range determines the classification, and adjacent ranges of the same classification are treated as one. For example, `192.0.0.0/24` is special-purpose and `192.0.0.9/32` is public. Prefixes that do not fit another classification, such as `10.0.0.0/7`, are public.
+
+| Classification  | Address space                                                                                             |
+|-----------------|-----------------------------------------------------------------------------------------------------------|
+| Public          | All other space, including globally reachable blocks such as 192.0.0.9/32 and 64:ff9b::/96                 |
+| Private         | 10.0.0.0/8, 172.16.0.0/12 and 192.168.0.0/16 (RFC 1918), fc00::/7 (RFC 4193)                               |
+| Shared          | 100.64.0.0/10 (RFC 6598)                                                                                  |
+| Loopback        | 127.0.0.0/8, ::1/128                                                                                      |
+| Link-local      | 169.254.0.0/16, fe80::/10                                                                                 |
+| Documentation   | 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24, 2001:db8::/32, 3fff::/20                                   |
+| Multicast       | 224.0.0.0/4, ff00::/8                                                                                     |
+| Benchmarking    | 198.18.0.0/15, 2001:2::/48                                                                                |
+| Special-purpose | Other blocks of the IANA special-purpose registries which are not marked globally reachable (including deprecated blocks, TEREDO and 6to4), and IPv6 space reserved by the IETF |
+
+!!! note
+    Custom validators use `classification`, which reflects the current prefix. Object permission constraints and direct ORM queries use the stored copy, `_classification`.
+
 ### Status
 
 The prefix's operational status. Note that the status of a prefix does _not_ have any impact on its member [IP addresses](./ipaddress.md), which may have their statuses defined independently. The "container" status indicates that the prefix exists merely as a container for organizing child prefixes.

+ 4 - 3
netbox/ipam/api/serializers_/ip.py

@@ -49,6 +49,7 @@ class AggregateSerializer(PrimaryModelSerializer):
 
 class PrefixSerializer(PrimaryModelSerializer):
     family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
+    classification = ChoiceField(choices=PrefixClassificationChoices, read_only=True)
     vrf = VRFSerializer(nested=True, required=False, allow_null=True)
     scope_type = ContentTypeField(
         queryset=ContentType.objects.filter(
@@ -71,9 +72,9 @@ class PrefixSerializer(PrimaryModelSerializer):
     class Meta:
         model = Prefix
         fields = [
-            'id', 'url', 'display_url', 'display', 'family', 'prefix', 'vrf', 'scope_type', 'scope_id', 'scope',
-            'tenant', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description', 'owner', 'comments', 'tags',
-            'custom_fields', 'created', 'last_updated', 'children', '_depth',
+            'id', 'url', 'display_url', 'display', 'family', 'prefix', 'classification', 'vrf', 'scope_type',
+            'scope_id', 'scope', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description', 'owner',
+            'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'children', '_depth',
         ]
         brief_fields = ('id', 'url', 'display', 'family', 'prefix', 'description', '_depth')
 

+ 70 - 0
netbox/ipam/choices.py

@@ -34,6 +34,76 @@ class PrefixStatusChoices(ChoiceSet):
     ]
 
 
+class PrefixClassificationChoices(ChoiceSet):
+
+    CLASSIFICATION_PUBLIC = 'public'
+    CLASSIFICATION_PRIVATE = 'private'
+    CLASSIFICATION_SHARED = 'shared'
+    CLASSIFICATION_LOOPBACK = 'loopback'
+    CLASSIFICATION_LINK_LOCAL = 'link-local'
+    CLASSIFICATION_DOCUMENTATION = 'documentation'
+    CLASSIFICATION_MULTICAST = 'multicast'
+    CLASSIFICATION_BENCHMARKING = 'benchmarking'
+    CLASSIFICATION_SPECIAL = 'special'
+
+    CHOICES = (
+        Choice(
+            CLASSIFICATION_PUBLIC,
+            _('Public'),
+            color='blue',
+            description=_('Address space outside the other classifications')
+        ),
+        Choice(
+            CLASSIFICATION_PRIVATE,
+            _('Private'),
+            color='green',
+            description=_('RFC 1918 and unique local address space')
+        ),
+        Choice(
+            CLASSIFICATION_SHARED,
+            _('Shared'),
+            color='teal',
+            description=_('Carrier-grade NAT address space')
+        ),
+        Choice(
+            CLASSIFICATION_LOOPBACK,
+            _('Loopback'),
+            color='gray',
+            description=_('Host loopback addresses')
+        ),
+        Choice(
+            CLASSIFICATION_LINK_LOCAL,
+            _('Link-local'),
+            color='cyan',
+            description=_('Valid only on a single network link')
+        ),
+        Choice(
+            CLASSIFICATION_DOCUMENTATION,
+            _('Documentation'),
+            color='purple',
+            description=_('Reserved for documentation and examples')
+        ),
+        Choice(
+            CLASSIFICATION_MULTICAST,
+            _('Multicast'),
+            color='indigo',
+            description=_('Multicast group addresses')
+        ),
+        Choice(
+            CLASSIFICATION_BENCHMARKING,
+            _('Benchmarking'),
+            color='pink',
+            description=_('Reserved for network benchmark tests')
+        ),
+        Choice(
+            CLASSIFICATION_SPECIAL,
+            _('Special-purpose'),
+            color='dark',
+            description=_('Other reserved or special-purpose space')
+        ),
+    )
+
+
 #
 # IP Ranges
 #

+ 64 - 1
netbox/ipam/constants.py

@@ -1,6 +1,6 @@
 from django.db.models import Q
 
-from .choices import FHRPGroupProtocolChoices, IPAddressRoleChoices
+from .choices import FHRPGroupProtocolChoices, IPAddressRoleChoices, PrefixClassificationChoices
 
 #
 # VRFs
@@ -22,6 +22,69 @@ VRF_RD_MAX_LENGTH = 21
 PREFIX_LENGTH_MIN = 1
 PREFIX_LENGTH_MAX = 127  # IPv6
 
+# Address space by classification per the IANA registries, matched tier by tier in get_prefix_classification(). A change
+# needs a migration which replaces ipam_prefix_classification() and reclassifies prefixes overlapping changed blocks.
+PREFIX_CLASSIFICATION_NETWORKS = {
+    PrefixClassificationChoices.CLASSIFICATION_PRIVATE: (
+        '10.0.0.0/8',  # RFC 1918
+        '172.16.0.0/12',  # RFC 1918
+        '192.168.0.0/16',  # RFC 1918
+        'fc00::/7',  # RFC 4193
+    ),
+    PrefixClassificationChoices.CLASSIFICATION_SHARED: (
+        '100.64.0.0/10',  # RFC 6598
+    ),
+    PrefixClassificationChoices.CLASSIFICATION_LOOPBACK: (
+        '127.0.0.0/8',  # RFC 1122
+        '::1/128',  # RFC 4291
+    ),
+    PrefixClassificationChoices.CLASSIFICATION_LINK_LOCAL: (
+        '169.254.0.0/16',  # RFC 3927
+        'fe80::/10',  # RFC 4291
+    ),
+    PrefixClassificationChoices.CLASSIFICATION_DOCUMENTATION: (
+        '192.0.2.0/24',  # RFC 5737
+        '198.51.100.0/24',  # RFC 5737
+        '203.0.113.0/24',  # RFC 5737
+        '2001:db8::/32',  # RFC 3849
+        '3fff::/20',  # RFC 9637
+    ),
+    PrefixClassificationChoices.CLASSIFICATION_MULTICAST: (
+        '224.0.0.0/4',  # RFC 5771
+        'ff00::/8',  # RFC 4291
+    ),
+    PrefixClassificationChoices.CLASSIFICATION_BENCHMARKING: (
+        '198.18.0.0/15',  # RFC 2544
+        '2001:2::/48',  # RFC 5180
+    ),
+    PrefixClassificationChoices.CLASSIFICATION_SPECIAL: (
+        '0.0.0.0/8',  # RFC 791
+        '192.0.0.0/24',  # RFC 6890
+        '192.88.99.0/24',  # RFC 7526
+        '240.0.0.0/4',  # RFC 1112
+        '::/8', '100::/8', '200::/7', '400::/6', '800::/5', '1000::/4',  # Reserved by IETF
+        '2001::/23',  # RFC 2928
+        '2002::/16',  # RFC 3056
+        '4000::/3', '6000::/3', '8000::/3', 'a000::/3', 'c000::/3',  # Reserved by IETF
+        'e000::/4', 'f000::/5', 'f800::/6', 'fe00::/9',  # Reserved by IETF
+        'fec0::/10',  # RFC 3879
+    ),
+}
+
+# Globally reachable blocks within special-purpose space
+PREFIX_CLASSIFICATION_PUBLIC_EXCEPTIONS = (
+    '192.0.0.9/32',  # RFC 7723
+    '192.0.0.10/32',  # RFC 8155
+    '64:ff9b::/96',  # RFC 6052
+    '2001:1::1/128',  # RFC 7723
+    '2001:1::2/128',  # RFC 8155
+    '2001:1::3/128',  # RFC 9665
+    '2001:3::/32',  # RFC 7450
+    '2001:4:112::/48',  # RFC 7535
+    '2001:20::/28',  # RFC 7343
+    '2001:30::/28',  # RFC 9374
+)
+
 
 #
 # IPAddresses

+ 5 - 0
netbox/ipam/filtersets.py

@@ -431,6 +431,11 @@ class PrefixFilterSet(PrimaryModelFilterSet, ScopedFilterSet, TenancyFilterSet,
         distinct=False,
         null_value=None
     )
+    classification = django_filters.MultipleChoiceFilter(
+        field_name='_classification',
+        choices=PrefixClassificationChoices,
+        distinct=False,
+    )
 
     class Meta:
         model = Prefix

+ 13 - 3
netbox/ipam/forms/filtersets.py

@@ -8,7 +8,12 @@ from ipam.models import *
 from netbox.forms import NetBoxModelFilterSetForm, OrganizationalModelFilterSetForm, PrimaryModelFilterSetForm
 from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
 from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, add_blank_choice
-from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField
+from utilities.forms.fields import (
+    DynamicModelChoiceField,
+    DynamicModelMultipleChoiceField,
+    MultipleChoiceField,
+    TagFilterField,
+)
 from utilities.forms.rendering import FieldSet
 from virtualization.models import Cluster, ClusterGroup, VirtualMachine
 from vpn.models import L2VPN
@@ -192,8 +197,8 @@ class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFi
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet(
-            'within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized',
-            name=_('Addressing')
+            'within_include', 'family', 'classification', 'status', 'role_id', 'mask_length', 'is_pool',
+            'mark_utilized', name=_('Addressing')
         ),
         FieldSet('vlan_group_id', 'vlan_id', name=_('VLAN Assignment')),
         FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
@@ -240,6 +245,11 @@ class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFi
         choices=PrefixStatusChoices,
         required=False
     )
+    classification = MultipleChoiceField(
+        label=_('Classification'),
+        choices=PrefixClassificationChoices,
+        required=False
+    )
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         required=False,

+ 2 - 0
netbox/ipam/graphql/enums.py

@@ -9,6 +9,7 @@ __all__ = (
     'IPAddressRoleEnum',
     'IPAddressStatusEnum',
     'IPRangeStatusEnum',
+    'PrefixClassificationEnum',
     'PrefixStatusEnum',
     'ServiceProtocolEnum',
     'VLANQinQRoleEnum',
@@ -21,6 +22,7 @@ IPAddressFamilyEnum = strawberry.enum(IPAddressFamilyChoices.as_enum(prefix='fam
 IPAddressRoleEnum = strawberry.enum(IPAddressRoleChoices.as_enum(prefix='role'))
 IPAddressStatusEnum = strawberry.enum(IPAddressStatusChoices.as_enum(prefix='status'))
 IPRangeStatusEnum = strawberry.enum(IPRangeStatusChoices.as_enum(prefix='status'))
+PrefixClassificationEnum = strawberry.enum(PrefixClassificationChoices.as_enum(prefix='classification'))
 PrefixStatusEnum = strawberry.enum(PrefixStatusChoices.as_enum(prefix='status'))
 ServiceProtocolEnum = strawberry.enum(ServiceProtocolChoices.as_enum())
 VLANStatusEnum = strawberry.enum(VLANStatusChoices.as_enum(prefix='status'))

+ 3 - 0
netbox/ipam/graphql/filters.py

@@ -289,6 +289,9 @@ class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, Pr
     status: BaseFilterLookup[Annotated['PrefixStatusEnum', strawberry.lazy('ipam.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )
+    _classification: BaseFilterLookup[
+        Annotated['PrefixClassificationEnum', strawberry.lazy('ipam.graphql.enums')]
+    ] | None = strawberry_django.filter_field(name='classification')
     role: Annotated['RoleFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
     role_id: ID | None = strawberry_django.filter_field()
     is_pool: FilterLookup[bool] | None = strawberry_django.filter_field()

+ 5 - 1
netbox/ipam/graphql/types.py

@@ -210,7 +210,7 @@ class IPRangeType(ContactsMixin, PrimaryObjectType):
 
 @register_type(
     models.Prefix,
-    exclude=['scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'],
+    exclude=['scope_type', 'scope_id', '_classification', '_location', '_region', '_site', '_site_group'],
     filters=PrefixFilter,
     pagination=True
 )
@@ -226,6 +226,10 @@ class PrefixType(ContactsMixin, PrimaryObjectType):
     def family(self) -> IPAddressFamilyType:
         return IPAddressFamilyType(value=self.family, label=f'IPv{self.family}')
 
+    @strawberry_django.field(only=['prefix'])
+    def classification(self) -> str:
+        return self.classification
+
     @strawberry_django.field(
         prefetch_related=build_gfk_prefetch(
             'scope',

+ 62 - 0
netbox/ipam/migrations/0098_prefix_classification.py

@@ -0,0 +1,62 @@
+from django.db import migrations, models
+
+# Frozen copy of the ipam.constants rules. A later change replaces the function in a new migration.
+CLASSIFICATION_CASE = """CASE
+    WHEN prefix <<= ANY (ARRAY[
+        '192.0.0.9/32', '192.0.0.10/32', '64:ff9b::/96', '2001:1::1/128', '2001:1::2/127', '2001:3::/32',
+        '2001:4:112::/48', '2001:20::/27'
+    ]::cidr[]) THEN 'public'
+    WHEN prefix <<= ANY (ARRAY['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', 'fc00::/7']::cidr[]) THEN 'private'
+    WHEN prefix <<= ANY (ARRAY['100.64.0.0/10']::cidr[]) THEN 'shared'
+    WHEN prefix <<= ANY (ARRAY['127.0.0.0/8', '::1/128']::cidr[]) THEN 'loopback'
+    WHEN prefix <<= ANY (ARRAY['169.254.0.0/16', 'fe80::/10']::cidr[]) THEN 'link-local'
+    WHEN prefix <<= ANY (ARRAY[
+        '192.0.2.0/24', '198.51.100.0/24', '203.0.113.0/24', '2001:db8::/32', '3fff::/20'
+    ]::cidr[]) THEN 'documentation'
+    WHEN prefix <<= ANY (ARRAY['224.0.0.0/4', 'ff00::/8']::cidr[]) THEN 'multicast'
+    WHEN prefix <<= ANY (ARRAY['198.18.0.0/15', '2001:2::/48']::cidr[]) THEN 'benchmarking'
+    WHEN prefix <<= ANY (ARRAY[
+        '0.0.0.0/8', '192.0.0.0/24', '192.88.99.0/24', '240.0.0.0/4', '::/3', '2001::/23', '2002::/16',
+        '4000::/2', '8000::/2', 'c000::/3', 'e000::/4', 'f000::/5', 'f800::/6', 'fe00::/9', 'fec0::/10'
+    ]::cidr[]) THEN 'special'
+    ELSE 'public'
+END"""
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('ipam', '0097_merge_ipaddress_host_index_and_multi_protocol_services'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='prefix',
+            name='_classification',
+            field=models.CharField(db_index=True, default='public', editable=False, max_length=50),
+        ),
+        migrations.RunSQL(
+            sql=[
+                'CREATE OR REPLACE FUNCTION ipam_prefix_classification(prefix cidr) RETURNS text '
+                f'LANGUAGE sql IMMUTABLE AS $$ SELECT {CLASSIFICATION_CASE} $$',
+                """
+                CREATE OR REPLACE FUNCTION ipam_prefix_classification_fn() RETURNS trigger LANGUAGE plpgsql AS $$
+                BEGIN
+                    NEW._classification := ipam_prefix_classification(NEW.prefix);
+                    RETURN NEW;
+                END
+                $$
+                """,
+                'DROP TRIGGER IF EXISTS ipam_prefix_classification ON ipam_prefix',
+                """
+                CREATE TRIGGER ipam_prefix_classification
+                    BEFORE INSERT OR UPDATE OF prefix, _classification ON ipam_prefix
+                    FOR EACH ROW EXECUTE FUNCTION ipam_prefix_classification_fn()
+                """,
+            ],
+            reverse_sql=[
+                'DROP TRIGGER IF EXISTS ipam_prefix_classification ON ipam_prefix',
+                'DROP FUNCTION IF EXISTS ipam_prefix_classification_fn()',
+                'DROP FUNCTION IF EXISTS ipam_prefix_classification(cidr)',
+            ],
+        ),
+    ]

+ 33 - 0
netbox/ipam/migrations/0099_populate_prefix_classification.py

@@ -0,0 +1,33 @@
+from django.db import migrations
+from django.db.models import CharField, F, Func
+
+BATCH_SIZE = 5000
+
+
+def populate_prefix_classification(apps, schema_editor):
+    """
+    Classify existing prefixes in batches, skipping rows whose stored value is already correct.
+    """
+    Prefix = apps.get_model('ipam', 'Prefix')
+    prefixes = Prefix.objects.using(schema_editor.connection.alias)
+    classification = Func(F('prefix'), function='ipam_prefix_classification', output_field=CharField())
+    pending = prefixes.exclude(_classification=classification).order_by('pk')
+
+    # Primary keys may be zero or negative, so the first batch has no lower bound
+    batch = pending
+    while pks := list(batch.values_list('pk', flat=True)[:BATCH_SIZE]):
+        prefixes.filter(pk__in=pks).update(_classification=classification)
+        batch = pending.filter(pk__gt=pks[-1])
+
+
+class Migration(migrations.Migration):
+    # Kept apart from 0098, so that its ALTER TABLE lock is released before the backfill runs
+    atomic = False
+
+    dependencies = [
+        ('ipam', '0098_prefix_classification'),
+    ]
+
+    operations = [
+        migrations.RunPython(code=populate_prefix_classification, reverse_code=migrations.RunPython.noop),
+    ]

+ 24 - 0
netbox/ipam/models/ip.py

@@ -16,6 +16,7 @@ from ipam.fields import IPAddressField, IPNetworkField
 from ipam.lookups import Host
 from ipam.managers import IPAddressManager
 from ipam.querysets import IPRangeQuerySet, PrefixQuerySet
+from ipam.utils import get_prefix_classification
 from ipam.validators import DNSValidator
 from netbox.config import get_config
 from netbox.models import OrganizationalModel, PrimaryModel
@@ -281,6 +282,16 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
         editable=False
     )
 
+    # Cached classification for filtering, maintained by a database trigger
+    _classification = models.CharField(
+        verbose_name=_('classification'),
+        max_length=50,
+        choices=PrefixClassificationChoices,
+        default=PrefixClassificationChoices.CLASSIFICATION_PUBLIC,
+        editable=False,
+        db_index=True,
+    )
+
     objects = PrefixQuerySet.as_manager()
 
     clone_fields = (
@@ -339,6 +350,9 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
         # Cache objects associated with the terminating object (for filtering)
         self.cache_related_objects()
 
+        # Update the cached classification for change logging
+        self._classification = self.classification
+
         super().save(*args, **kwargs)
 
     @property
@@ -357,6 +371,10 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
             return netaddr.IPNetwork(self.prefix).prefixlen
         return self.prefix.prefixlen
 
+    @property
+    def classification(self):
+        return get_prefix_classification(self.prefix)
+
     @property
     def ipv6_full(self):
         if self.prefix and self.prefix.version == 6:
@@ -383,6 +401,12 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
     def get_status_color(self):
         return PrefixStatusChoices.colors.get(self.status)
 
+    def get_classification_color(self):
+        return PrefixClassificationChoices.colors.get(self.classification)
+
+    def get_classification_display(self):
+        return dict(PrefixClassificationChoices).get(self.classification)
+
     @cached_property
     def aggregate(self):
         """

+ 9 - 3
netbox/ipam/tables/ip.py

@@ -187,6 +187,12 @@ class PrefixTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable):
         verbose_name=_('Status'),
         default=AVAILABLE_LABEL
     )
+    # Read the property so unsaved prefixes display correctly
+    classification = columns.ChoiceFieldColumn(
+        verbose_name=_('Classification'),
+        accessor='get_classification_display',
+        order_by=('_classification',)
+    )
     vrf = tables.TemplateColumn(
         template_code=VRF_LINK,
         verbose_name=_('VRF')
@@ -236,9 +242,9 @@ class PrefixTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable):
     class Meta(PrimaryModelTable.Meta):
         model = Prefix
         fields = (
-            'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'tenant_group',
-            'scope', 'scope_type', 'vlan_group', 'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'contacts',
-            'comments', 'tags', 'created', 'last_updated',
+            'pk', 'id', 'prefix', 'prefix_flat', 'status', 'classification', 'children', 'vrf', 'utilization',
+            'tenant', 'tenant_group', 'scope', 'scope_type', 'vlan_group', 'vlan', 'role', 'is_pool', 'mark_utilized',
+            'description', 'contacts', 'comments', 'tags', 'created', 'last_updated',
         )
         default_columns = (
             'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'scope', 'vlan', 'role',

+ 52 - 1
netbox/ipam/tests/test_api.py

@@ -13,7 +13,14 @@ from ipam.choices import *
 from ipam.models import *
 from tenancy.models import Tenant
 from utilities.data import string_to_ranges
-from utilities.testing import APITestCase, APIViewTestCases, create_tags, create_test_device, disable_logging
+from utilities.testing import (
+    APITestCase,
+    APIViewTestCases,
+    GraphQLFilterTest,
+    create_tags,
+    create_test_device,
+    disable_logging,
+)
 
 
 class AppTestCase(APITestCase):
@@ -503,6 +510,13 @@ class PrefixTestCase(APIViewTestCases.APIViewTestCase):
     bulk_update_invalid_data = {
         'status': 'not-a-valid-status',
     }
+    graphql_filter_tests = (
+        GraphQLFilterTest(
+            name='classification',
+            filters='classification: {exact: CLASSIFICATION_PUBLIC}',
+            expected=lambda qs: qs.filter(prefix='2001:4860::/32'),
+        ),
+    )
 
     @classmethod
     def setUpTestData(cls):
@@ -511,6 +525,7 @@ class PrefixTestCase(APIViewTestCases.APIViewTestCase):
             Prefix(prefix=IPNetwork('192.168.1.0/24')),
             Prefix(prefix=IPNetwork('192.168.2.0/24')),
             Prefix(prefix=IPNetwork('192.168.3.0/24')),
+            Prefix(prefix=IPNetwork('2001:4860::/32')),
         )
         Prefix.objects.bulk_create(prefixes)
 
@@ -539,6 +554,39 @@ class PrefixTestCase(APIViewTestCases.APIViewTestCase):
         response = self.client.patch(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_200_OK)
 
+    def test_classification_is_read_only(self):
+        """The classification follows the prefix and ignores a submitted value."""
+        self.add_permissions('ipam.add_prefix', 'ipam.change_prefix')
+        url = reverse('ipam-api:prefix-list')
+        data = {'prefix': '10.10.0.0/16', 'classification': PrefixClassificationChoices.CLASSIFICATION_PUBLIC}
+        response = self.client.post(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(response.data['classification']['value'], PrefixClassificationChoices.CLASSIFICATION_PRIVATE)
+
+        url = reverse('ipam-api:prefix-detail', kwargs={'pk': response.data['id']})
+        response = self.client.patch(url, {'prefix': '8.8.8.0/24'}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data['classification']['value'], PrefixClassificationChoices.CLASSIFICATION_PUBLIC)
+
+    def test_graphql_classification(self):
+        """GraphQL returns the classification of each prefix."""
+        self.add_permissions('ipam.view_prefix')
+        url = reverse('graphql')
+        query = '{ prefix_list { prefix classification } }'
+        response = self.client.post(url, data={'query': query}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        data = response.json()
+        self.assertNotIn('errors', data)
+        self.assertEqual(
+            {row['prefix']: row['classification'] for row in data['data']['prefix_list']},
+            {
+                '192.168.1.0/24': PrefixClassificationChoices.CLASSIFICATION_PRIVATE,
+                '192.168.2.0/24': PrefixClassificationChoices.CLASSIFICATION_PRIVATE,
+                '192.168.3.0/24': PrefixClassificationChoices.CLASSIFICATION_PRIVATE,
+                '2001:4860::/32': PrefixClassificationChoices.CLASSIFICATION_PUBLIC,
+            },
+        )
+
     def test_list_available_prefixes(self):
         """
         Test retrieval of all available prefixes within a parent prefix.
@@ -623,6 +671,9 @@ class PrefixTestCase(APIViewTestCases.APIViewTestCase):
             self.assertEqual(response.data['is_pool'], True)
             self.assertEqual(response.data['tenant']['id'], tenant.pk)
             self.assertEqual(response.data['role']['id'], role.pk)
+            self.assertEqual(
+                response.data['classification']['value'], PrefixClassificationChoices.CLASSIFICATION_DOCUMENTATION
+            )
 
         # Try to create one more prefix
         response = self.client.post(url, {'prefix_length': 30}, format='json', **self.header)

+ 12 - 0
netbox/ipam/tests/test_filtersets.py

@@ -909,6 +909,18 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'status': [PrefixStatusChoices.STATUS_DEPRECATED, PrefixStatusChoices.STATUS_RESERVED]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
+    def test_classification(self):
+        """Filter prefixes by address space classification."""
+        params = {'classification': [PrefixClassificationChoices.CLASSIFICATION_PRIVATE]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
+        params = {'classification': [
+            PrefixClassificationChoices.CLASSIFICATION_PRIVATE,
+            PrefixClassificationChoices.CLASSIFICATION_DOCUMENTATION,
+        ]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 10)
+        params = {'classification__n': [PrefixClassificationChoices.CLASSIFICATION_PRIVATE]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
+
     def test_tenant(self):
         tenants = Tenant.objects.all()[:2]
         params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}

+ 132 - 1
netbox/ipam/tests/test_models.py

@@ -1,13 +1,21 @@
+import re
+
 import netaddr
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
+from django.db import connection
 from django.db.backends.postgresql.psycopg_any import NumericRange
 from django.test import TestCase, override_settings
 from netaddr import IPNetwork, IPSet
 
 from dcim.models import Location, Region, Site, SiteGroup
 from ipam.choices import *
-from ipam.constants import SERVICE_PORT_MAX, SERVICE_PORT_MIN
+from ipam.constants import (
+    PREFIX_CLASSIFICATION_NETWORKS,
+    PREFIX_CLASSIFICATION_PUBLIC_EXCEPTIONS,
+    SERVICE_PORT_MAX,
+    SERVICE_PORT_MIN,
+)
 from ipam.models import *
 from ipam.utils import port_mapping_q, rebuild_prefixes
 from utilities.data import string_to_ranges
@@ -384,6 +392,129 @@ class PrefixTestCase(TestCase):
         prefix_v6 = Prefix(prefix='2001:db8::/32')
         self.assertEqual(prefix_v6.mask_length, 32)
 
+    def test_classification(self):
+        """Each prefix is classified by the address space it covers, in Python and in the database."""
+        expected = {
+            IPNetwork('8.8.8.0/24'): PrefixClassificationChoices.CLASSIFICATION_PUBLIC,
+            IPNetwork('10.0.0.0/8'): PrefixClassificationChoices.CLASSIFICATION_PRIVATE,
+            IPNetwork('10.0.0.0/7'): PrefixClassificationChoices.CLASSIFICATION_PUBLIC,
+            IPNetwork('0.0.0.0/7'): PrefixClassificationChoices.CLASSIFICATION_PUBLIC,
+            IPNetwork('100.64.0.0/10'): PrefixClassificationChoices.CLASSIFICATION_SHARED,
+            IPNetwork('100.64.1.0/24'): PrefixClassificationChoices.CLASSIFICATION_SHARED,
+            IPNetwork('127.0.0.0/8'): PrefixClassificationChoices.CLASSIFICATION_LOOPBACK,
+            IPNetwork('169.254.0.0/16'): PrefixClassificationChoices.CLASSIFICATION_LINK_LOCAL,
+            IPNetwork('192.0.0.0/24'): PrefixClassificationChoices.CLASSIFICATION_SPECIAL,
+            IPNetwork('192.0.0.8/31'): PrefixClassificationChoices.CLASSIFICATION_SPECIAL,
+            IPNetwork('192.0.0.8/32'): PrefixClassificationChoices.CLASSIFICATION_SPECIAL,
+            IPNetwork('192.0.0.9/32'): PrefixClassificationChoices.CLASSIFICATION_PUBLIC,
+            IPNetwork('192.0.2.0/24'): PrefixClassificationChoices.CLASSIFICATION_DOCUMENTATION,
+            IPNetwork('198.18.0.0/15'): PrefixClassificationChoices.CLASSIFICATION_BENCHMARKING,
+            IPNetwork('224.0.0.0/4'): PrefixClassificationChoices.CLASSIFICATION_MULTICAST,
+            IPNetwork('224.0.0.0/3'): PrefixClassificationChoices.CLASSIFICATION_PUBLIC,
+            IPNetwork('240.0.0.0/4'): PrefixClassificationChoices.CLASSIFICATION_SPECIAL,
+            IPNetwork('2001:4860::/32'): PrefixClassificationChoices.CLASSIFICATION_PUBLIC,
+            IPNetwork('2000::/3'): PrefixClassificationChoices.CLASSIFICATION_PUBLIC,
+            IPNetwork('fd00::/8'): PrefixClassificationChoices.CLASSIFICATION_PRIVATE,
+            IPNetwork('::1/128'): PrefixClassificationChoices.CLASSIFICATION_LOOPBACK,
+            IPNetwork('::ffff:10.0.0.0/104'): PrefixClassificationChoices.CLASSIFICATION_SPECIAL,
+            IPNetwork('fe80::/10'): PrefixClassificationChoices.CLASSIFICATION_LINK_LOCAL,
+            IPNetwork('2001:db8::/32'): PrefixClassificationChoices.CLASSIFICATION_DOCUMENTATION,
+            IPNetwork('3fff::/20'): PrefixClassificationChoices.CLASSIFICATION_DOCUMENTATION,
+            IPNetwork('ff00::/8'): PrefixClassificationChoices.CLASSIFICATION_MULTICAST,
+            IPNetwork('2001:2::/48'): PrefixClassificationChoices.CLASSIFICATION_BENCHMARKING,
+            IPNetwork('2001::/32'): PrefixClassificationChoices.CLASSIFICATION_SPECIAL,
+            IPNetwork('2001:1::3/128'): PrefixClassificationChoices.CLASSIFICATION_PUBLIC,
+            IPNetwork('2001:1::/126'): PrefixClassificationChoices.CLASSIFICATION_SPECIAL,
+            IPNetwork('2001:20::/27'): PrefixClassificationChoices.CLASSIFICATION_PUBLIC,
+            IPNetwork('64:ff9b::/96'): PrefixClassificationChoices.CLASSIFICATION_PUBLIC,
+            IPNetwork('64:ff9b::/95'): PrefixClassificationChoices.CLASSIFICATION_SPECIAL,
+            IPNetwork('fec0::/10'): PrefixClassificationChoices.CLASSIFICATION_SPECIAL,
+            IPNetwork('4000::/2'): PrefixClassificationChoices.CLASSIFICATION_SPECIAL,
+        }
+        Prefix.objects.bulk_create(Prefix(prefix=prefix) for prefix in expected)
+        self.assertEqual(dict(Prefix.objects.values_list('prefix', '_classification')), expected)
+        self.assertEqual({prefix.prefix: prefix.classification for prefix in Prefix.objects.all()}, expected)
+
+    def test_classification_before_save(self):
+        """The classification follows unsaved changes to the prefix without querying the database."""
+        with self.assertNumQueries(0):
+            prefix = Prefix(prefix=IPNetwork('10.0.0.0/8'))
+            self.assertEqual(prefix.classification, PrefixClassificationChoices.CLASSIFICATION_PRIVATE)
+            prefix.prefix_length = 7
+            self.assertEqual(prefix.classification, PrefixClassificationChoices.CLASSIFICATION_PUBLIC)
+            prefix.prefix = '2001:db8::/32'
+            self.assertEqual(prefix.classification, PrefixClassificationChoices.CLASSIFICATION_DOCUMENTATION)
+            self.assertIsNone(Prefix().classification)
+            self.assertIsNone(Prefix(prefix='invalid').classification)
+
+    @override_settings(CUSTOM_VALIDATORS={
+        'ipam.prefix': [{'classification': {'eq': PrefixClassificationChoices.CLASSIFICATION_PRIVATE}}],
+    })
+    def test_classification_custom_validator(self):
+        """Custom validators check the classification of the prefix being saved."""
+        Prefix(prefix=IPNetwork('10.0.0.0/8')).full_clean()
+        with self.assertRaisesMessage(ValidationError, 'Custom validation failed for classification'):
+            Prefix(prefix=IPNetwork('8.8.8.0/24')).full_clean()
+        prefix = Prefix.objects.create(prefix=IPNetwork('10.1.0.0/16'))
+        prefix.prefix = IPNetwork('8.8.8.0/24')
+        with self.assertRaisesMessage(ValidationError, 'Custom validation failed for classification'):
+            prefix.full_clean()
+
+    def test_classification_follows_prefix(self):
+        """The stored classification follows the prefix on every write path and ignores direct writes."""
+        prefix = Prefix.objects.create(prefix=IPNetwork('10.1.0.0/16'))
+        self.assertEqual(prefix._classification, PrefixClassificationChoices.CLASSIFICATION_PRIVATE)
+        prefix.prefix = IPNetwork('8.8.0.0/16')
+        prefix.save()
+        self.assertEqual(prefix._classification, PrefixClassificationChoices.CLASSIFICATION_PUBLIC)
+        prefix.prefix = IPNetwork('100.64.0.0/16')
+        prefix.save(update_fields=['prefix'])
+        prefix.refresh_from_db()
+        self.assertEqual(prefix._classification, PrefixClassificationChoices.CLASSIFICATION_SHARED)
+        Prefix.objects.filter(pk=prefix.pk).update(prefix=IPNetwork('192.0.2.0/24'))
+        prefix.refresh_from_db()
+        self.assertEqual(prefix._classification, PrefixClassificationChoices.CLASSIFICATION_DOCUMENTATION)
+        prefix.prefix = IPNetwork('fe80::/64')
+        Prefix.objects.bulk_update([prefix], ['prefix'])
+        prefix.refresh_from_db()
+        self.assertEqual(prefix._classification, PrefixClassificationChoices.CLASSIFICATION_LINK_LOCAL)
+        Prefix.objects.filter(pk=prefix.pk).update(_classification=PrefixClassificationChoices.CLASSIFICATION_PUBLIC)
+        prefix.refresh_from_db()
+        self.assertEqual(prefix._classification, PrefixClassificationChoices.CLASSIFICATION_LINK_LOCAL)
+
+    def test_classification_matches_database(self):
+        """The database classifies every boundary of its rules and the constants like the classification property."""
+        # Sampling the installed rules too catches a rule removed from the constants without a migration
+        with connection.cursor() as cursor:
+            cursor.execute("SELECT prosrc FROM pg_proc WHERE oid = 'ipam_prefix_classification(cidr)'::regprocedure")
+            installed = re.findall(r"'([0-9a-f.:]+/\d+)'", cursor.fetchone()[0])
+        prefixes = set()
+        for networks in (installed, PREFIX_CLASSIFICATION_PUBLIC_EXCEPTIONS, *PREFIX_CLASSIFICATION_NETWORKS.values()):
+            for network in map(IPNetwork, networks):
+                prefixes.add(network)
+                prefixes.add(IPNetwork(f'{network.network}/{network.prefixlen - 1}').cidr)
+                bits = 32 if network.version == 4 else 128
+                for address in (network.first - 1, network.first, network.last, network.last + 1):
+                    if 0 <= address < 2 ** bits:
+                        prefixes.add(IPNetwork(netaddr.IPAddress(address, network.version)))
+        Prefix.objects.bulk_create(Prefix(prefix=prefix) for prefix in prefixes)
+        for prefix in Prefix.objects.all():
+            with self.subTest(prefix=prefix.prefix):
+                self.assertEqual(prefix._classification, prefix.classification)
+
+    def test_classification_rules(self):
+        """The rule tiers keep the invariants their precedence relies on."""
+        rules = dict(PREFIX_CLASSIFICATION_NETWORKS)
+        special = IPSet(rules.pop(PrefixClassificationChoices.CLASSIFICATION_SPECIAL))
+        exceptions = IPSet(PREFIX_CLASSIFICATION_PUBLIC_EXCEPTIONS)
+        claimed = IPSet(exceptions)
+        for networks in rules.values():
+            self.assertFalse(claimed & IPSet(networks))
+            claimed |= IPSet(networks)
+        self.assertTrue(exceptions.issubset(special))
+        for network in special.iter_cidrs():
+            self.assertTrue(IPSet([network]) - exceptions, network)
+
     def test_get_duplicates(self):
         prefixes = Prefix.objects.bulk_create((
             Prefix(prefix=IPNetwork('192.0.2.0/24')),

+ 22 - 1
netbox/ipam/tests/test_tables.py

@@ -4,7 +4,7 @@ from netaddr import IPNetwork
 from dcim.models import Site
 from ipam.models import ASN, RIR, FHRPGroupAssignment, IPAddress, IPRange, Prefix
 from ipam.tables import *
-from ipam.utils import annotate_ip_space
+from ipam.utils import add_requested_prefixes, annotate_ip_space
 from utilities.testing import TableTestCases
 
 
@@ -204,6 +204,27 @@ class RoleTableTestCase(TableTestCases.StandardTableTestCase):
 class PrefixTableTestCase(TableTestCases.StandardTableTestCase):
     table = PrefixTable
 
+    def test_classification_column(self):
+        """Saved and available-space rows show the classification of their own prefix."""
+        saved = Prefix.objects.create(prefix=IPNetwork('8.8.8.0/24'))
+        available = Prefix(prefix=IPNetwork('10.0.1.0/24'), status=None)
+        table = PrefixTable([saved, available])
+        table.configure(self.get_request())
+        rows = list(table.rows)
+
+        self.assertHTMLEqual(rows[0].get_cell('classification'), '<span class="badge text-bg-blue">Public</span>')
+        self.assertHTMLEqual(rows[1].get_cell('classification'), '<span class="badge text-bg-green">Private</span>')
+        self.assertEqual(rows[0].get_cell_value('classification'), 'Public')
+        self.assertEqual(rows[1].get_cell_value('classification'), 'Private')
+
+    def test_classification_ordering(self):
+        """Available-space rows sort by the classification of their own prefix."""
+        parent = Prefix.objects.create(prefix=IPNetwork('10.0.0.0/7'))
+        Prefix.objects.create(prefix=IPNetwork('11.0.0.0/8'))
+        data = add_requested_prefixes(parent.prefix, parent.get_child_prefixes())
+        table = PrefixTable(data, order_by='-classification')
+        self.assertEqual([prefix.prefix for prefix in table.data], [IPNetwork('11.0.0.0/8'), IPNetwork('10.0.0.0/8')])
+
 
 class IPRangeTableTestCase(TableTestCases.StandardTableTestCase):
     table = IPRangeTable

+ 1 - 0
netbox/ipam/ui/panels.py

@@ -137,6 +137,7 @@ class IPAddressPanel(panels.ObjectAttributesPanel):
 
 class PrefixPanel(panels.ObjectAttributesPanel):
     family = attrs.TextAttr('family', format_string='IPv{}', label=_('Family'))
+    classification = attrs.ChoiceAttr('classification')
     vrf = VRFDisplayAttr('vrf', label=_('VRF'))
     tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
     aggregate = attrs.TemplatedAttr(

+ 41 - 1
netbox/ipam/utils.py

@@ -1,4 +1,5 @@
 from dataclasses import dataclass
+from functools import cache
 
 import netaddr
 from django.apps import apps
@@ -6,6 +7,7 @@ from django.core.exceptions import ValidationError
 from django.db.models import BooleanField, F, Func, Q
 from django.utils.translation import gettext_lazy as _
 
+from .choices import PrefixClassificationChoices
 from .constants import *
 
 __all__ = (
@@ -17,6 +19,7 @@ __all__ = (
     'annotate_ip_space',
     'expand_port_mapping',
     'get_next_available_prefix',
+    'get_prefix_classification',
     'group_port_mapping_rows',
     'group_port_mappings',
     'legacy_protocol_and_ports',
@@ -67,7 +70,10 @@ def add_requested_prefixes(parent, prefix_list, show_available=True, show_assign
         # saved Prefix instances with real pks, bulk delete will fail for mixed-type selections
         # due to single-model form validation. See: https://github.com/netbox-community/netbox/issues/21176
         available_prefixes = netaddr.IPSet(parent) ^ netaddr.IPSet([p.prefix for p in prefix_list])
-        available_prefixes = [Prefix(prefix=p, status=None) for p in available_prefixes.iter_cidrs()]
+        available_prefixes = [
+            Prefix(prefix=p, status=None, _classification=get_prefix_classification(p))
+            for p in available_prefixes.iter_cidrs()
+        ]
         child_prefixes = child_prefixes + available_prefixes
 
     # Add assigned prefixes to the table if requested
@@ -306,6 +312,40 @@ def parse_inet_addresses(value):
     return parsed
 
 
+@cache
+def _prefix_classification_rules():
+    """
+    Return each classification tier in match order, collapsed to (version, first, last) bounds.
+    """
+    special = PrefixClassificationChoices.CLASSIFICATION_SPECIAL
+    tiers = (
+        (PrefixClassificationChoices.CLASSIFICATION_PUBLIC, PREFIX_CLASSIFICATION_PUBLIC_EXCEPTIONS),
+        *((name, networks) for name, networks in PREFIX_CLASSIFICATION_NETWORKS.items() if name != special),
+        (special, PREFIX_CLASSIFICATION_NETWORKS[special]),
+    )
+    return tuple(
+        (name, tuple((cidr.version, cidr.first, cidr.last) for cidr in netaddr.IPSet(networks).iter_cidrs()))
+        for name, networks in tiers
+    )
+
+
+def get_prefix_classification(prefix):
+    """
+    Return the classification of the address space a prefix covers, or None for a missing or invalid prefix.
+    """
+    if not isinstance(prefix, netaddr.IPNetwork):
+        try:
+            prefix = netaddr.IPNetwork(prefix)
+        except (netaddr.AddrFormatError, TypeError):
+            return None
+    version, first, last = prefix.version, prefix.first, prefix.last
+    for classification, bounds in _prefix_classification_rules():
+        for family, start, end in bounds:
+            if version == family and start <= first and last <= end:
+                return classification
+    return PrefixClassificationChoices.CLASSIFICATION_PUBLIC
+
+
 #
 # Service port mappings
 #