ソースを参照

feat(ipam): Classify prefixes by address space

Adds a read-only classification to prefixes, computed by PostgreSQL
from the IANA registries. It can be filtered in the UI, REST API and
GraphQL, and is shown on the prefix view and as an optional table
column.

Fixes #21609
Martin Hauser 4 日 前
親
コミット
04d5227a99

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

@@ -8,6 +8,26 @@ 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
+
+The kind of address space the prefix covers, derived automatically from the prefix and read-only. A prefix that lies entirely within one class takes that class, and a prefix that spans several is classified as mixed. Public covers all space no other class claims. It describes the address space only, not whether the prefix is allocated to you, announced or reachable.
+
+| 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 |
+| Mixed           | More than one of the above                                                                                |
+
+!!! note "Custom validation"
+    The database computes the classification when the prefix is saved. A custom validator therefore cannot read it for a new prefix, and sees the stored classification rather than the edited one for an existing prefix. Validators which check the address space should inspect `prefix` directly.
+
 ### 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')
 
 

+ 77 - 0
netbox/ipam/choices.py

@@ -34,6 +34,83 @@ 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'
+    CLASSIFICATION_MIXED = 'mixed'
+
+    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')
+        ),
+        Choice(
+            CLASSIFICATION_MIXED,
+            _('Mixed'),
+            color='orange',
+            description=_('Spans more than one classification')
+        ),
+    )
+
+
 #
 #
 # 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. Named classes and public exceptions override the special
+# ranges enclosing them, and any change needs a migration which drops and re-adds Prefix.classification.
+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

@@ -430,6 +430,11 @@ class PrefixFilterSet(PrimaryModelFilterSet, ScopedFilterSet, TenancyFilterSet,
         distinct=False,
         distinct=False,
         null_value=None
         null_value=None
     )
     )
+    classification = django_filters.MultipleChoiceFilter(
+        choices=PrefixClassificationChoices,
+        distinct=False,
+        null_value=None
+    )
 
 
     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()
     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()

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

@@ -0,0 +1,167 @@
+from django.db import migrations, models
+
+
+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.GeneratedField(
+                db_persist=True,
+                expression=models.Case(
+                    models.When(
+                        models.Q(
+                            ('prefix__net_contained_or_equal', '192.0.0.9/32'),
+                            ('prefix__net_contained_or_equal', '192.0.0.10/32'),
+                            ('prefix__net_contained_or_equal', '64:ff9b::/96'),
+                            ('prefix__net_contained_or_equal', '2001:1::1/128'),
+                            ('prefix__net_contained_or_equal', '2001:1::2/127'),
+                            ('prefix__net_contained_or_equal', '2001:3::/32'),
+                            ('prefix__net_contained_or_equal', '2001:4:112::/48'),
+                            ('prefix__net_contained_or_equal', '2001:20::/27'),
+                            _connector='OR',
+                        ),
+                        then=models.Value('public'),
+                    ),
+                    models.When(
+                        models.Q(
+                            ('prefix__net_contained_or_equal', '10.0.0.0/8'),
+                            ('prefix__net_contained_or_equal', '172.16.0.0/12'),
+                            ('prefix__net_contained_or_equal', '192.168.0.0/16'),
+                            ('prefix__net_contained_or_equal', 'fc00::/7'),
+                            _connector='OR',
+                        ),
+                        then=models.Value('private'),
+                    ),
+                    models.When(
+                        models.Q(('prefix__net_contained_or_equal', '100.64.0.0/10'), _connector='OR'),
+                        then=models.Value('shared'),
+                    ),
+                    models.When(
+                        models.Q(
+                            ('prefix__net_contained_or_equal', '127.0.0.0/8'),
+                            ('prefix__net_contained_or_equal', '::1/128'),
+                            _connector='OR',
+                        ),
+                        then=models.Value('loopback'),
+                    ),
+                    models.When(
+                        models.Q(
+                            ('prefix__net_contained_or_equal', '169.254.0.0/16'),
+                            ('prefix__net_contained_or_equal', 'fe80::/10'),
+                            _connector='OR',
+                        ),
+                        then=models.Value('link-local'),
+                    ),
+                    models.When(
+                        models.Q(
+                            ('prefix__net_contained_or_equal', '192.0.2.0/24'),
+                            ('prefix__net_contained_or_equal', '198.51.100.0/24'),
+                            ('prefix__net_contained_or_equal', '203.0.113.0/24'),
+                            ('prefix__net_contained_or_equal', '2001:db8::/32'),
+                            ('prefix__net_contained_or_equal', '3fff::/20'),
+                            _connector='OR',
+                        ),
+                        then=models.Value('documentation'),
+                    ),
+                    models.When(
+                        models.Q(
+                            ('prefix__net_contained_or_equal', '224.0.0.0/4'),
+                            ('prefix__net_contained_or_equal', 'ff00::/8'),
+                            _connector='OR',
+                        ),
+                        then=models.Value('multicast'),
+                    ),
+                    models.When(
+                        models.Q(
+                            ('prefix__net_contained_or_equal', '198.18.0.0/15'),
+                            ('prefix__net_contained_or_equal', '2001:2::/48'),
+                            _connector='OR',
+                        ),
+                        then=models.Value('benchmarking'),
+                    ),
+                    models.When(
+                        models.Q(
+                            ('prefix__net_contains', '192.0.0.9/32'),
+                            ('prefix__net_contains', '192.0.0.10/32'),
+                            ('prefix__net_contains', '64:ff9b::/96'),
+                            ('prefix__net_contains', '2001:1::1/128'),
+                            ('prefix__net_contains', '2001:1::2/127'),
+                            ('prefix__net_contains', '2001:3::/32'),
+                            ('prefix__net_contains', '2001:4:112::/48'),
+                            ('prefix__net_contains', '2001:20::/27'),
+                            ('prefix__net_contains', '10.0.0.0/8'),
+                            ('prefix__net_contains', '172.16.0.0/12'),
+                            ('prefix__net_contains', '192.168.0.0/16'),
+                            ('prefix__net_contains', 'fc00::/7'),
+                            ('prefix__net_contains', '100.64.0.0/10'),
+                            ('prefix__net_contains', '127.0.0.0/8'),
+                            ('prefix__net_contains', '::1/128'),
+                            ('prefix__net_contains', '169.254.0.0/16'),
+                            ('prefix__net_contains', 'fe80::/10'),
+                            ('prefix__net_contains', '192.0.2.0/24'),
+                            ('prefix__net_contains', '198.51.100.0/24'),
+                            ('prefix__net_contains', '203.0.113.0/24'),
+                            ('prefix__net_contains', '2001:db8::/32'),
+                            ('prefix__net_contains', '3fff::/20'),
+                            ('prefix__net_contains', '224.0.0.0/4'),
+                            ('prefix__net_contains', 'ff00::/8'),
+                            ('prefix__net_contains', '198.18.0.0/15'),
+                            ('prefix__net_contains', '2001:2::/48'),
+                            _connector='OR',
+                        ),
+                        then=models.Value('mixed'),
+                    ),
+                    models.When(
+                        models.Q(
+                            ('prefix__net_contained_or_equal', '0.0.0.0/8'),
+                            ('prefix__net_contained_or_equal', '192.0.0.0/24'),
+                            ('prefix__net_contained_or_equal', '192.88.99.0/24'),
+                            ('prefix__net_contained_or_equal', '240.0.0.0/4'),
+                            ('prefix__net_contained_or_equal', '::/3'),
+                            ('prefix__net_contained_or_equal', '2001::/23'),
+                            ('prefix__net_contained_or_equal', '2002::/16'),
+                            ('prefix__net_contained_or_equal', '4000::/2'),
+                            ('prefix__net_contained_or_equal', '8000::/2'),
+                            ('prefix__net_contained_or_equal', 'c000::/3'),
+                            ('prefix__net_contained_or_equal', 'e000::/4'),
+                            ('prefix__net_contained_or_equal', 'f000::/5'),
+                            ('prefix__net_contained_or_equal', 'f800::/6'),
+                            ('prefix__net_contained_or_equal', 'fe00::/9'),
+                            ('prefix__net_contained_or_equal', 'fec0::/10'),
+                            _connector='OR',
+                        ),
+                        then=models.Value('special'),
+                    ),
+                    models.When(
+                        models.Q(
+                            ('prefix__net_contains', '0.0.0.0/8'),
+                            ('prefix__net_contains', '192.0.0.0/24'),
+                            ('prefix__net_contains', '192.88.99.0/24'),
+                            ('prefix__net_contains', '240.0.0.0/4'),
+                            ('prefix__net_contains', '::/3'),
+                            ('prefix__net_contains', '2001::/23'),
+                            ('prefix__net_contains', '2002::/16'),
+                            ('prefix__net_contains', '4000::/2'),
+                            ('prefix__net_contains', '8000::/2'),
+                            ('prefix__net_contains', 'c000::/3'),
+                            ('prefix__net_contains', 'e000::/4'),
+                            ('prefix__net_contains', 'f000::/5'),
+                            ('prefix__net_contains', 'f800::/6'),
+                            ('prefix__net_contains', 'fe00::/9'),
+                            ('prefix__net_contains', 'fec0::/10'),
+                            _connector='OR',
+                        ),
+                        then=models.Value('mixed'),
+                    ),
+                    default=models.Value('public'),
+                    output_field=models.CharField(max_length=50),
+                ),
+                output_field=models.CharField(max_length=50),
+            ),
+        ),
+    ]

+ 12 - 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 prefix_classification_expression
 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
@@ -223,6 +224,14 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
         verbose_name=_('prefix'),
         verbose_name=_('prefix'),
         help_text=_('IPv4 or IPv6 network with mask')
         help_text=_('IPv4 or IPv6 network with mask')
     )
     )
+    classification = models.GeneratedField(
+        expression=prefix_classification_expression('prefix'),
+        output_field=models.CharField(max_length=50),
+        db_persist=True,
+        choices=PrefixClassificationChoices,
+        verbose_name=_('classification'),
+        help_text=_('Address space classification derived from the prefix'),
+    )
     vrf = models.ForeignKey(
     vrf = models.ForeignKey(
         to='ipam.VRF',
         to='ipam.VRF',
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
@@ -383,6 +392,9 @@ 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)
+
     @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
     )
     )
+    # django-tables2 calls get_FOO_display() unguarded for choice fields, which fails on unsaved available-space rows
+    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',

+ 33 - 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,20 @@ 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_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 +652,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]}

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

@@ -7,7 +7,12 @@ 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 +389,79 @@ 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."""
+        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_MIXED,
+            IPNetwork('0.0.0.0/7'): PrefixClassificationChoices.CLASSIFICATION_MIXED,
+            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_MIXED,
+            IPNetwork('192.0.0.8/31'): PrefixClassificationChoices.CLASSIFICATION_MIXED,
+            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_MIXED,
+            IPNetwork('240.0.0.0/4'): PrefixClassificationChoices.CLASSIFICATION_SPECIAL,
+            IPNetwork('2001:4860::/32'): PrefixClassificationChoices.CLASSIFICATION_PUBLIC,
+            IPNetwork('2000::/3'): PrefixClassificationChoices.CLASSIFICATION_MIXED,
+            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_MIXED,
+            IPNetwork('2001:20::/27'): PrefixClassificationChoices.CLASSIFICATION_PUBLIC,
+            IPNetwork('64:ff9b::/96'): PrefixClassificationChoices.CLASSIFICATION_PUBLIC,
+            IPNetwork('64:ff9b::/95'): PrefixClassificationChoices.CLASSIFICATION_MIXED,
+            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)
+
+    def test_classification_follows_prefix(self):
+        """The classification follows the prefix through ORM creates and updates."""
+        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'])
+        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)
+
+    def test_classification_rules(self):
+        """The classification rules keep the invariants the generated expression 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')),

+ 13 - 0
netbox/ipam/tests/test_tables.py

@@ -204,6 +204,19 @@ class RoleTableTestCase(TableTestCases.StandardTableTestCase):
 class PrefixTableTestCase(TableTestCases.StandardTableTestCase):
 class PrefixTableTestCase(TableTestCases.StandardTableTestCase):
     table = PrefixTable
     table = PrefixTable
 
 
+    def test_classification_column(self):
+        """Saved rows show their classification and unsaved available-space rows the column default."""
+        saved = Prefix.objects.create(prefix=IPNetwork('10.0.0.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-green">Private</span>')
+        self.assertEqual(rows[1].get_cell('classification'), table.columns['classification'].default)
+        self.assertEqual(rows[0].get_cell_value('classification'), 'Private')
+        self.assertIsNone(rows[1].get_cell_value('classification'))
+
 
 
 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(

+ 40 - 1
netbox/ipam/utils.py

@@ -3,9 +3,10 @@ from dataclasses import dataclass
 import netaddr
 import netaddr
 from django.apps import apps
 from django.apps import apps
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
-from django.db.models import BooleanField, F, Func, Q
+from django.db.models import BooleanField, Case, CharField, F, Func, Q, Value, When
 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__ = (
@@ -23,6 +24,7 @@ __all__ = (
     'normalize_port_mapping',
     'normalize_port_mapping',
     'parse_inet_addresses',
     'parse_inet_addresses',
     'port_mapping_q',
     'port_mapping_q',
+    'prefix_classification_expression',
     'rebuild_prefixes',
     'rebuild_prefixes',
     'sorted_int_ports',
     'sorted_int_ports',
     'split_port_mapping',
     'split_port_mapping',
@@ -306,6 +308,43 @@ def parse_inet_addresses(value):
     return parsed
     return parsed
 
 
 
 
+def prefix_classification_expression(field_name):
+    """
+    Return a database expression which classifies the network in `field_name`. Public exceptions take precedence over
+    the named classes, which take precedence over special-purpose space. A network spanning several classes is mixed.
+    """
+    def collapse(networks):
+        return [str(cidr) for cidr in netaddr.IPSet(networks).iter_cidrs()]
+
+    def match(lookup, networks):
+        return Q.create([(f'{field_name}__{lookup}', network) for network in networks], connector=Q.OR)
+
+    named = {
+        classification: collapse(networks)
+        for classification, networks in PREFIX_CLASSIFICATION_NETWORKS.items()
+        if classification != PrefixClassificationChoices.CLASSIFICATION_SPECIAL
+    }
+    special = collapse(PREFIX_CLASSIFICATION_NETWORKS[PrefixClassificationChoices.CLASSIFICATION_SPECIAL])
+    exceptions = collapse(PREFIX_CLASSIFICATION_PUBLIC_EXCEPTIONS)
+    enclosed = exceptions + [network for networks in named.values() for network in networks]
+    public = PrefixClassificationChoices.CLASSIFICATION_PUBLIC
+    mixed = PrefixClassificationChoices.CLASSIFICATION_MIXED
+
+    return Case(
+        When(match('net_contained_or_equal', exceptions), then=Value(public)),
+        *[
+            When(match('net_contained_or_equal', networks), then=Value(classification))
+            for classification, networks in named.items()
+        ],
+        # Before the special match, as special blocks enclose some exceptions and named blocks
+        When(match('net_contains', enclosed), then=Value(mixed)),
+        When(match('net_contained_or_equal', special), then=Value(PrefixClassificationChoices.CLASSIFICATION_SPECIAL)),
+        When(match('net_contains', special), then=Value(mixed)),
+        default=Value(public),
+        output_field=CharField(max_length=50),
+    )
+
+
 #
 #
 # Service port mappings
 # Service port mappings
 #
 #