Просмотр исходного кода

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 часов назад
Родитель
Сommit
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.
 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
 ### 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.
 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):
 class PrefixSerializer(PrimaryModelSerializer):
     family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
     family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
+    classification = ChoiceField(choices=PrefixClassificationChoices, read_only=True)
     vrf = VRFSerializer(nested=True, required=False, allow_null=True)
     vrf = VRFSerializer(nested=True, required=False, allow_null=True)
     scope_type = ContentTypeField(
     scope_type = ContentTypeField(
         queryset=ContentType.objects.filter(
         queryset=ContentType.objects.filter(
@@ -71,9 +72,9 @@ class PrefixSerializer(PrimaryModelSerializer):
     class Meta:
     class Meta:
         model = Prefix
         model = Prefix
         fields = [
         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')
         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
 # IP Ranges
 #
 #

+ 64 - 1
netbox/ipam/constants.py

@@ -1,6 +1,6 @@
 from django.db.models import Q
 from django.db.models import Q
 
 
-from .choices import FHRPGroupProtocolChoices, IPAddressRoleChoices
+from .choices import FHRPGroupProtocolChoices, IPAddressRoleChoices, PrefixClassificationChoices
 
 
 #
 #
 # VRFs
 # VRFs
@@ -22,6 +22,69 @@ VRF_RD_MAX_LENGTH = 21
 PREFIX_LENGTH_MIN = 1
 PREFIX_LENGTH_MIN = 1
 PREFIX_LENGTH_MAX = 127  # IPv6
 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
 # IPAddresses

+ 5 - 0
netbox/ipam/filtersets.py

@@ -431,6 +431,11 @@ class PrefixFilterSet(PrimaryModelFilterSet, ScopedFilterSet, TenancyFilterSet,
         distinct=False,
         distinct=False,
         null_value=None
         null_value=None
     )
     )
+    classification = django_filters.MultipleChoiceFilter(
+        field_name='_classification',
+        choices=PrefixClassificationChoices,
+        distinct=False,
+    )
 
 
     class Meta:
     class Meta:
         model = Prefix
         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 netbox.forms import NetBoxModelFilterSetForm, OrganizationalModelFilterSetForm, PrimaryModelFilterSetForm
 from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
 from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
 from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, add_blank_choice
 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 utilities.forms.rendering import FieldSet
 from virtualization.models import Cluster, ClusterGroup, VirtualMachine
 from virtualization.models import Cluster, ClusterGroup, VirtualMachine
 from vpn.models import L2VPN
 from vpn.models import L2VPN
@@ -192,8 +197,8 @@ class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFi
     fieldsets = (
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet(
         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('vlan_group_id', 'vlan_id', name=_('VLAN Assignment')),
         FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
         FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
@@ -240,6 +245,11 @@ class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFi
         choices=PrefixStatusChoices,
         choices=PrefixStatusChoices,
         required=False
         required=False
     )
     )
+    classification = MultipleChoiceField(
+        label=_('Classification'),
+        choices=PrefixClassificationChoices,
+        required=False
+    )
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         required=False,
         required=False,

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

@@ -9,6 +9,7 @@ __all__ = (
     'IPAddressRoleEnum',
     'IPAddressRoleEnum',
     'IPAddressStatusEnum',
     'IPAddressStatusEnum',
     'IPRangeStatusEnum',
     'IPRangeStatusEnum',
+    'PrefixClassificationEnum',
     'PrefixStatusEnum',
     'PrefixStatusEnum',
     'ServiceProtocolEnum',
     'ServiceProtocolEnum',
     'VLANQinQRoleEnum',
     'VLANQinQRoleEnum',
@@ -21,6 +22,7 @@ IPAddressFamilyEnum = strawberry.enum(IPAddressFamilyChoices.as_enum(prefix='fam
 IPAddressRoleEnum = strawberry.enum(IPAddressRoleChoices.as_enum(prefix='role'))
 IPAddressRoleEnum = strawberry.enum(IPAddressRoleChoices.as_enum(prefix='role'))
 IPAddressStatusEnum = strawberry.enum(IPAddressStatusChoices.as_enum(prefix='status'))
 IPAddressStatusEnum = strawberry.enum(IPAddressStatusChoices.as_enum(prefix='status'))
 IPRangeStatusEnum = strawberry.enum(IPRangeStatusChoices.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'))
 PrefixStatusEnum = strawberry.enum(PrefixStatusChoices.as_enum(prefix='status'))
 ServiceProtocolEnum = strawberry.enum(ServiceProtocolChoices.as_enum())
 ServiceProtocolEnum = strawberry.enum(ServiceProtocolChoices.as_enum())
 VLANStatusEnum = strawberry.enum(VLANStatusChoices.as_enum(prefix='status'))
 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 = (
     status: BaseFilterLookup[Annotated['PrefixStatusEnum', strawberry.lazy('ipam.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         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: Annotated['RoleFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
     role_id: ID | None = strawberry_django.filter_field()
     role_id: ID | None = strawberry_django.filter_field()
     is_pool: FilterLookup[bool] | 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(
 @register_type(
     models.Prefix,
     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,
     filters=PrefixFilter,
     pagination=True
     pagination=True
 )
 )
@@ -226,6 +226,10 @@ class PrefixType(ContactsMixin, PrimaryObjectType):
     def family(self) -> IPAddressFamilyType:
     def family(self) -> IPAddressFamilyType:
         return IPAddressFamilyType(value=self.family, label=f'IPv{self.family}')
         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(
     @strawberry_django.field(
         prefetch_related=build_gfk_prefetch(
         prefetch_related=build_gfk_prefetch(
             'scope',
             '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.lookups import Host
 from ipam.managers import IPAddressManager
 from ipam.managers import IPAddressManager
 from ipam.querysets import IPRangeQuerySet, PrefixQuerySet
 from ipam.querysets import IPRangeQuerySet, PrefixQuerySet
+from ipam.utils import get_prefix_classification
 from ipam.validators import DNSValidator
 from ipam.validators import DNSValidator
 from netbox.config import get_config
 from netbox.config import get_config
 from netbox.models import OrganizationalModel, PrimaryModel
 from netbox.models import OrganizationalModel, PrimaryModel
@@ -281,6 +282,16 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
         editable=False
         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()
     objects = PrefixQuerySet.as_manager()
 
 
     clone_fields = (
     clone_fields = (
@@ -339,6 +350,9 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
         # Cache objects associated with the terminating object (for filtering)
         # Cache objects associated with the terminating object (for filtering)
         self.cache_related_objects()
         self.cache_related_objects()
 
 
+        # Update the cached classification for change logging
+        self._classification = self.classification
+
         super().save(*args, **kwargs)
         super().save(*args, **kwargs)
 
 
     @property
     @property
@@ -357,6 +371,10 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
             return netaddr.IPNetwork(self.prefix).prefixlen
             return netaddr.IPNetwork(self.prefix).prefixlen
         return self.prefix.prefixlen
         return self.prefix.prefixlen
 
 
+    @property
+    def classification(self):
+        return get_prefix_classification(self.prefix)
+
     @property
     @property
     def ipv6_full(self):
     def ipv6_full(self):
         if self.prefix and self.prefix.version == 6:
         if self.prefix and self.prefix.version == 6:
@@ -383,6 +401,12 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
     def get_status_color(self):
     def get_status_color(self):
         return PrefixStatusChoices.colors.get(self.status)
         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
     @cached_property
     def aggregate(self):
     def aggregate(self):
         """
         """

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

@@ -187,6 +187,12 @@ class PrefixTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable):
         verbose_name=_('Status'),
         verbose_name=_('Status'),
         default=AVAILABLE_LABEL
         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(
     vrf = tables.TemplateColumn(
         template_code=VRF_LINK,
         template_code=VRF_LINK,
         verbose_name=_('VRF')
         verbose_name=_('VRF')
@@ -236,9 +242,9 @@ class PrefixTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable):
     class Meta(PrimaryModelTable.Meta):
     class Meta(PrimaryModelTable.Meta):
         model = Prefix
         model = Prefix
         fields = (
         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 = (
         default_columns = (
             'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'scope', 'vlan', 'role',
             '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 ipam.models import *
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.data import string_to_ranges
 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):
 class AppTestCase(APITestCase):
@@ -503,6 +510,13 @@ class PrefixTestCase(APIViewTestCases.APIViewTestCase):
     bulk_update_invalid_data = {
     bulk_update_invalid_data = {
         'status': 'not-a-valid-status',
         '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
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -511,6 +525,7 @@ class PrefixTestCase(APIViewTestCases.APIViewTestCase):
             Prefix(prefix=IPNetwork('192.168.1.0/24')),
             Prefix(prefix=IPNetwork('192.168.1.0/24')),
             Prefix(prefix=IPNetwork('192.168.2.0/24')),
             Prefix(prefix=IPNetwork('192.168.2.0/24')),
             Prefix(prefix=IPNetwork('192.168.3.0/24')),
             Prefix(prefix=IPNetwork('192.168.3.0/24')),
+            Prefix(prefix=IPNetwork('2001:4860::/32')),
         )
         )
         Prefix.objects.bulk_create(prefixes)
         Prefix.objects.bulk_create(prefixes)
 
 
@@ -539,6 +554,39 @@ class PrefixTestCase(APIViewTestCases.APIViewTestCase):
         response = self.client.patch(url, data, format='json', **self.header)
         response = self.client.patch(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_200_OK)
         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):
     def test_list_available_prefixes(self):
         """
         """
         Test retrieval of all available prefixes within a parent prefix.
         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['is_pool'], True)
             self.assertEqual(response.data['tenant']['id'], tenant.pk)
             self.assertEqual(response.data['tenant']['id'], tenant.pk)
             self.assertEqual(response.data['role']['id'], role.pk)
             self.assertEqual(response.data['role']['id'], role.pk)
+            self.assertEqual(
+                response.data['classification']['value'], PrefixClassificationChoices.CLASSIFICATION_DOCUMENTATION
+            )
 
 
         # Try to create one more prefix
         # Try to create one more prefix
         response = self.client.post(url, {'prefix_length': 30}, format='json', **self.header)
         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]}
         params = {'status': [PrefixStatusChoices.STATUS_DEPRECATED, PrefixStatusChoices.STATUS_RESERVED]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         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):
     def test_tenant(self):
         tenants = Tenant.objects.all()[:2]
         tenants = Tenant.objects.all()[:2]
         params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
         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
 import netaddr
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
+from django.db import connection
 from django.db.backends.postgresql.psycopg_any import NumericRange
 from django.db.backends.postgresql.psycopg_any import NumericRange
 from django.test import TestCase, override_settings
 from django.test import TestCase, override_settings
 from netaddr import IPNetwork, IPSet
 from netaddr import IPNetwork, IPSet
 
 
 from dcim.models import Location, Region, Site, SiteGroup
 from dcim.models import Location, Region, Site, SiteGroup
 from ipam.choices import *
 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.models import *
 from ipam.utils import port_mapping_q, rebuild_prefixes
 from ipam.utils import port_mapping_q, rebuild_prefixes
 from utilities.data import string_to_ranges
 from utilities.data import string_to_ranges
@@ -384,6 +392,129 @@ class PrefixTestCase(TestCase):
         prefix_v6 = Prefix(prefix='2001:db8::/32')
         prefix_v6 = Prefix(prefix='2001:db8::/32')
         self.assertEqual(prefix_v6.mask_length, 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):
     def test_get_duplicates(self):
         prefixes = Prefix.objects.bulk_create((
         prefixes = Prefix.objects.bulk_create((
             Prefix(prefix=IPNetwork('192.0.2.0/24')),
             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 dcim.models import Site
 from ipam.models import ASN, RIR, FHRPGroupAssignment, IPAddress, IPRange, Prefix
 from ipam.models import ASN, RIR, FHRPGroupAssignment, IPAddress, IPRange, Prefix
 from ipam.tables import *
 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
 from utilities.testing import TableTestCases
 
 
 
 
@@ -204,6 +204,27 @@ class RoleTableTestCase(TableTestCases.StandardTableTestCase):
 class PrefixTableTestCase(TableTestCases.StandardTableTestCase):
 class PrefixTableTestCase(TableTestCases.StandardTableTestCase):
     table = PrefixTable
     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):
 class IPRangeTableTestCase(TableTestCases.StandardTableTestCase):
     table = IPRangeTable
     table = IPRangeTable

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

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

+ 41 - 1
netbox/ipam/utils.py

@@ -1,4 +1,5 @@
 from dataclasses import dataclass
 from dataclasses import dataclass
+from functools import cache
 
 
 import netaddr
 import netaddr
 from django.apps import apps
 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.db.models import BooleanField, F, Func, Q
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
+from .choices import PrefixClassificationChoices
 from .constants import *
 from .constants import *
 
 
 __all__ = (
 __all__ = (
@@ -17,6 +19,7 @@ __all__ = (
     'annotate_ip_space',
     'annotate_ip_space',
     'expand_port_mapping',
     'expand_port_mapping',
     'get_next_available_prefix',
     'get_next_available_prefix',
+    'get_prefix_classification',
     'group_port_mapping_rows',
     'group_port_mapping_rows',
     'group_port_mappings',
     'group_port_mappings',
     'legacy_protocol_and_ports',
     '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
         # 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
         # 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 = 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
         child_prefixes = child_prefixes + available_prefixes
 
 
     # Add assigned prefixes to the table if requested
     # Add assigned prefixes to the table if requested
@@ -306,6 +312,40 @@ def parse_inet_addresses(value):
     return parsed
     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
 # Service port mappings
 #
 #