2
0
Эх сурвалжийг харах

Closes #18040: Add a MACPrefix model to track blocks of MAC addresses

Brian Tiemann 9 цаг өмнө
parent
commit
afb1f7a099

+ 2 - 0
docs/features/devices-cabling.md

@@ -91,6 +91,8 @@ One especially nice feature of modules is that templated components can be autom
 
 
 [MAC addresses](../models/dcim/macaddress.md) are modeled as first-class objects in NetBox so that an interface may have multiple MAC addresses assigned to it, with one optionally designated as the interface's primary MAC. This accommodates virtual interfaces and modular hardware where the link-layer address is not necessarily fixed at the factory. MAC addresses can be assigned to both [device interfaces](../models/dcim/interface.md) and [virtual machine interfaces](../models/virtualization/vminterface.md).
 [MAC addresses](../models/dcim/macaddress.md) are modeled as first-class objects in NetBox so that an interface may have multiple MAC addresses assigned to it, with one optionally designated as the interface's primary MAC. This accommodates virtual interfaces and modular hardware where the link-layer address is not necessarily fixed at the factory. MAC addresses can be assigned to both [device interfaces](../models/dcim/interface.md) and [virtual machine interfaces](../models/virtualization/vminterface.md).
 
 
+[MAC prefixes](../models/dcim/macprefix.md) represent blocks of MAC addresses, such as organizationally unique identifiers (OUIs). They can be used to track the utilization of a block, to create MAC addresses from the next available address in a block, and to prevent duplicate MAC addresses within a block.
+
 ## Cables
 ## Cables
 
 
 NetBox models cables as connections among certain types of device components and other objects. Each cable can be assigned a type, color, length, and label. NetBox will enforce basic sanity checks to prevent invalid connections. (For example, a network interface cannot be connected to a power outlet.)
 NetBox models cables as connections among certain types of device components and other objects. Each cable can be assigned a type, color, length, and label. NetBox will enforce basic sanity checks to prevent invalid connections. (For example, a network interface cannot be connected to a power outlet.)

+ 2 - 0
docs/models/dcim/macaddress.md

@@ -4,6 +4,8 @@ A MAC address object in NetBox represents a single Ethernet link-layer address a
 
 
 Most physical interfaces have only a single MAC address, hard-coded at the factory. However, some interfaces (particularly virtual interfaces and modular hardware) support multiple or reassignable MAC addresses. To accommodate this, NetBox models MAC addresses as first-class objects which may be created, modified, and reassigned independently of any specific interface.
 Most physical interfaces have only a single MAC address, hard-coded at the factory. However, some interfaces (particularly virtual interfaces and modular hardware) support multiple or reassignable MAC addresses. To accommodate this, NetBox models MAC addresses as first-class objects which may be created, modified, and reassigned independently of any specific interface.
 
 
+Blocks of MAC addresses, such as organizationally unique identifiers (OUIs), can be tracked as [MAC prefixes](./macprefix.md).
+
 ## Fields
 ## Fields
 
 
 ### MAC Address
 ### MAC Address

+ 33 - 0
docs/models/dcim/macprefix.md

@@ -0,0 +1,33 @@
+# MAC Prefixes
+
+A MAC prefix represents a block of [MAC addresses](./macaddress.md) which share a common leading sequence of bits. For example, an organizationally unique identifier (OUI) such as `BC:24:11` is a MAC prefix with a length of 24 bits, which spans the MAC addresses `BC:24:11:00:00:00` through `BC:24:11:FF:FF:FF`. MAC prefixes can also represent the smaller blocks assigned by the IEEE (MA-M and MA-S blocks have lengths of 28 and 36 bits, respectively), or blocks within an OUI set aside for a particular purpose, such as a virtualization cluster.
+
+A MAC address belongs to every MAC prefix which contains it: MAC addresses are not explicitly assigned to prefixes. The MAC address view shows the most specific prefix which contains it.
+
+Each MAC prefix shows its utilization (the percentage of its MAC addresses which exist in NetBox), and its MAC addresses. A new MAC address can be created from the first available address within the prefix, using either the UI or the REST API (`/api/dcim/mac-prefixes/<id>/available-mac-addresses/`).
+
+## Fields
+
+### Prefix
+
+The first MAC address in the block, for example `BC:24:11:00:00:00`. Any bits beyond the prefix length are cleared when the prefix is saved.
+
+### Prefix Length
+
+The number of leading bits which are shared by all MAC addresses in the block (1 to 48). Defaults to 24, the length of an OUI.
+
+### Name
+
+An optional name for the block, such as the organization to which it is assigned.
+
+### Enforce Unique
+
+If enabled, duplicate MAC addresses are not permitted within this prefix. Uniqueness cannot be enforced on a prefix which already contains duplicate MAC addresses.
+
+### Description
+
+An optional human-readable description of the MAC prefix.
+
+### Comments
+
+Free-form Markdown-supported notes regarding the MAC prefix.

+ 1 - 0
mkdocs.yml

@@ -225,6 +225,7 @@ nav:
             - InventoryItemTemplate: 'models/dcim/inventoryitemtemplate.md'
             - InventoryItemTemplate: 'models/dcim/inventoryitemtemplate.md'
             - Location: 'models/dcim/location.md'
             - Location: 'models/dcim/location.md'
             - MACAddress: 'models/dcim/macaddress.md'
             - MACAddress: 'models/dcim/macaddress.md'
+            - MACPrefix: 'models/dcim/macprefix.md'
             - Manufacturer: 'models/dcim/manufacturer.md'
             - Manufacturer: 'models/dcim/manufacturer.md'
             - Module: 'models/dcim/module.md'
             - Module: 'models/dcim/module.md'
             - ModuleBay: 'models/dcim/modulebay.md'
             - ModuleBay: 'models/dcim/modulebay.md'

+ 29 - 1
netbox/dcim/api/serializers_/devices.py

@@ -8,7 +8,7 @@ from rest_framework import serializers
 
 
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import MACADDRESS_ASSIGNMENT_MODELS
 from dcim.constants import MACADDRESS_ASSIGNMENT_MODELS
-from dcim.models import Device, DeviceBay, MACAddress, Module, VirtualDeviceContext
+from dcim.models import Device, DeviceBay, MACAddress, MACPrefix, Module, VirtualDeviceContext
 from dcim.utils import get_module_bay_positions, resolve_module_placeholder
 from dcim.utils import get_module_bay_positions, resolve_module_placeholder
 from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
 from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
 from ipam.api.serializers_.ip import IPAddressSerializer
 from ipam.api.serializers_.ip import IPAddressSerializer
@@ -27,8 +27,10 @@ from .sites import LocationSerializer, SiteSerializer
 from .virtualchassis import VirtualChassisSerializer
 from .virtualchassis import VirtualChassisSerializer
 
 
 __all__ = (
 __all__ = (
+    'AvailableMACAddressSerializer',
     'DeviceSerializer',
     'DeviceSerializer',
     'MACAddressSerializer',
     'MACAddressSerializer',
+    'MACPrefixSerializer',
     'ModuleSerializer',
     'ModuleSerializer',
     'VirtualDeviceContextSerializer',
     'VirtualDeviceContextSerializer',
 )
 )
@@ -359,3 +361,29 @@ class MACAddressSerializer(PrimaryModelSerializer):
             'last_updated',
             'last_updated',
         ]
         ]
         brief_fields = ('id', 'url', 'display', 'mac_address', 'description')
         brief_fields = ('id', 'url', 'display', 'mac_address', 'description')
+
+
+class MACPrefixSerializer(PrimaryModelSerializer):
+    mac_address_count = serializers.IntegerField(read_only=True)
+
+    class Meta:
+        model = MACPrefix
+        fields = [
+            'id', 'url', 'display_url', 'display', 'prefix', 'prefix_length', 'name', 'enforce_unique',
+            'mac_address_count', 'description', 'owner', 'comments', 'tags', 'custom_fields', 'created',
+            'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'prefix', 'prefix_length', 'name', 'description')
+
+
+class AvailableMACAddressSerializer(serializers.Serializer):
+    """
+    Representation of a MAC address which does not exist in the database.
+    """
+    mac_address = serializers.CharField(read_only=True)
+    description = serializers.CharField(required=False)
+
+    def to_representation(self, instance):
+        return {
+            'mac_address': str(instance),
+        }

+ 13 - 1
netbox/dcim/api/urls.py

@@ -1,3 +1,5 @@
+from django.urls import path
+
 from netbox.api.routers import NetBoxRouter
 from netbox.api.routers import NetBoxRouter
 
 
 from . import views
 from . import views
@@ -65,6 +67,7 @@ router.register('inventory-item-roles', views.InventoryItemRoleViewSet)
 
 
 # Addressing
 # Addressing
 router.register('mac-addresses', views.MACAddressViewSet)
 router.register('mac-addresses', views.MACAddressViewSet)
+router.register('mac-prefixes', views.MACPrefixViewSet)
 
 
 # Cables
 # Cables
 router.register('cables', views.CableViewSet)
 router.register('cables', views.CableViewSet)
@@ -86,4 +89,13 @@ router.register('cooling-feeds', views.CoolingFeedViewSet)
 router.register('connected-device', views.ConnectedDeviceViewSet, basename='connected-device')
 router.register('connected-device', views.ConnectedDeviceViewSet, basename='connected-device')
 
 
 app_name = 'dcim-api'
 app_name = 'dcim-api'
-urlpatterns = router.urls
+
+urlpatterns = [
+    path(
+        'mac-prefixes/<int:pk>/available-mac-addresses/',
+        views.MACPrefixAvailableMACAddressesView.as_view(),
+        name='macprefix-available-mac-addresses'
+    ),
+]
+
+urlpatterns += router.urls

+ 41 - 0
netbox/dcim/api/views.py

@@ -1,3 +1,5 @@
+from itertools import islice
+
 from django.contrib.contenttypes.prefetch import GenericPrefetch
 from django.contrib.contenttypes.prefetch import GenericPrefetch
 from django.db.models import FETCH_PEERS, Prefetch
 from django.db.models import FETCH_PEERS, Prefetch
 from django.http import Http404, HttpResponse
 from django.http import Http404, HttpResponse
@@ -14,10 +16,12 @@ from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
 from dcim.models import *
 from dcim.models import *
 from dcim.svg import CableTraceSVG
 from dcim.svg import CableTraceSVG
 from extras.api.mixins import ConfigContextQuerySetMixin, RenderConfigMixin
 from extras.api.mixins import ConfigContextQuerySetMixin, RenderConfigMixin
+from ipam.api.views import AvailableObjectsView
 from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
 from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
 from netbox.api.metadata import ContentTypeMetadata
 from netbox.api.metadata import ContentTypeMetadata
 from netbox.api.pagination import StripCountAnnotationsPaginator
 from netbox.api.pagination import StripCountAnnotationsPaginator
 from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet
 from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet
+from netbox.config import get_config
 from utilities.api import get_serializer_for_model
 from utilities.api import get_serializer_for_model
 from utilities.query import count_related
 from utilities.query import count_related
 from utilities.query_functions import CollateAsChar
 from utilities.query_functions import CollateAsChar
@@ -603,6 +607,43 @@ class MACAddressViewSet(NetBoxModelViewSet):
     filterset_class = filtersets.MACAddressFilterSet
     filterset_class = filtersets.MACAddressFilterSet
 
 
 
 
+class MACPrefixViewSet(NetBoxModelViewSet):
+    queryset = MACPrefix.objects.annotate_mac_address_count()
+    serializer_class = serializers.MACPrefixSerializer
+    filterset_class = filtersets.MACPrefixFilterSet
+
+
+class MACPrefixAvailableMACAddressesView(AvailableObjectsView):
+    queryset = MACAddress.objects.all()
+    read_serializer_class = serializers.AvailableMACAddressSerializer
+    write_serializer_class = serializers.AvailableMACAddressSerializer
+    advisory_lock_key = 'available-mac-addresses'
+
+    def get_parent(self, request, pk):
+        return get_object_or_404(MACPrefix.objects.restrict(request.user), pk=pk)
+
+    def get_available_objects(self, parent, limit=None):
+        # A prefix may contain millions of available MAC addresses, so always apply a limit
+        return list(islice(parent.iter_available_mac_addresses(), limit or get_config().PAGINATE_COUNT))
+
+    def prep_object_data(self, requested_objects, available_objects, parent):
+        for i, request_data in enumerate(requested_objects):
+            request_data['mac_address'] = str(available_objects[i])
+        return requested_objects
+
+    @extend_schema(methods=['get'], responses={200: serializers.AvailableMACAddressSerializer(many=True)})
+    def get(self, request, pk):
+        return super().get(request, pk)
+
+    @extend_schema(
+        methods=['post'],
+        responses={201: serializers.MACAddressSerializer(many=True)},
+        request=serializers.MACAddressSerializer(many=True),
+    )
+    def post(self, request, pk):
+        return super().post(request, pk)
+
+
 #
 #
 # Cables
 # Cables
 #
 #

+ 9 - 0
netbox/dcim/constants.py

@@ -85,6 +85,15 @@ NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES + [
 ]
 ]
 
 
 
 
+#
+# MAC prefixes
+#
+
+MACPREFIX_LENGTH_MIN = 1
+MACPREFIX_LENGTH_MAX = 48
+MACPREFIX_LENGTH_DEFAULT = 24  # An organizationally unique identifier (OUI)
+
+
 #
 #
 # Device components
 # Device components
 #
 #

+ 45 - 0
netbox/dcim/filtersets.py

@@ -79,6 +79,7 @@ __all__ = (
     'InventoryItemTemplateFilterSet',
     'InventoryItemTemplateFilterSet',
     'LocationFilterSet',
     'LocationFilterSet',
     'MACAddressFilterSet',
     'MACAddressFilterSet',
+    'MACPrefixFilterSet',
     'ManufacturerFilterSet',
     'ManufacturerFilterSet',
     'ModuleBayFilterSet',
     'ModuleBayFilterSet',
     'ModuleBayTemplateFilterSet',
     'ModuleBayTemplateFilterSet',
@@ -2287,6 +2288,10 @@ class MACAddressFilterSet(PrimaryModelFilterSet):
         method='filter_primary',
         method='filter_primary',
         label=_('Is primary'),
         label=_('Is primary'),
     )
     )
+    mac_prefix_id = MultiValueBigNumberFilter(
+        method='filter_mac_prefix',
+        label=_('MAC prefix (ID)'),
+    )
 
 
     class Meta:
     class Meta:
         model = MACAddress
         model = MACAddress
@@ -2344,6 +2349,46 @@ class MACAddressFilterSet(PrimaryModelFilterSet):
             return queryset.filter(query)
             return queryset.filter(query)
         return queryset.exclude(query)
         return queryset.exclude(query)
 
 
+    def filter_mac_prefix(self, queryset, name, value):
+        query = Q()
+        for mac_prefix in MACPrefix.objects.filter(pk__in=value):
+            query |= Q(mac_address__gte=mac_prefix.first_address, mac_address__lte=mac_prefix.last_address)
+        if not query:
+            return queryset.none()
+        return queryset.filter(query)
+
+
+@register_filterset
+class MACPrefixFilterSet(PrimaryModelFilterSet):
+    prefix = MultiValueMACAddressFilter()
+    contains = django_filters.CharFilter(
+        method='filter_contains',
+        label=_('Contains MAC address'),
+    )
+
+    class Meta:
+        model = MACPrefix
+        fields = ('id', 'prefix_length', 'name', 'enforce_unique', 'description')
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        qs_filter = (
+            Q(prefix__icontains=value) |
+            Q(name__icontains=value) |
+            Q(description__icontains=value)
+        )
+        return queryset.filter(qs_filter)
+
+    def filter_contains(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        try:
+            mac_address = netaddr.EUI(value.strip(), version=48)
+        except (AddrFormatError, TypeError, ValueError):
+            return queryset.none()
+        return queryset.filter(prefix__lte=mac_address, _last_address__gte=mac_address)
+
 
 
 class CommonInterfaceFilterSet(django_filters.FilterSet):
 class CommonInterfaceFilterSet(django_filters.FilterSet):
     mode = django_filters.MultipleChoiceFilter(
     mode = django_filters.MultipleChoiceFilter(

+ 20 - 0
netbox/dcim/forms/bulk_edit.py

@@ -63,6 +63,7 @@ __all__ = (
     'InventoryItemTemplateBulkEditForm',
     'InventoryItemTemplateBulkEditForm',
     'LocationBulkEditForm',
     'LocationBulkEditForm',
     'MACAddressBulkEditForm',
     'MACAddressBulkEditForm',
+    'MACPrefixBulkEditForm',
     'ManufacturerBulkEditForm',
     'ManufacturerBulkEditForm',
     'ModuleBayBulkEditForm',
     'ModuleBayBulkEditForm',
     'ModuleBayTemplateBulkEditForm',
     'ModuleBayTemplateBulkEditForm',
@@ -2283,3 +2284,22 @@ class MACAddressBulkEditForm(PrimaryModelBulkEditForm):
         FieldSet('description'),
         FieldSet('description'),
     )
     )
     nullable_fields = ('description', 'comments')
     nullable_fields = ('description', 'comments')
+
+
+class MACPrefixBulkEditForm(PrimaryModelBulkEditForm):
+    name = forms.CharField(
+        label=_('Name'),
+        max_length=100,
+        required=False
+    )
+    enforce_unique = forms.NullBooleanField(
+        label=_('Enforce unique'),
+        required=False,
+        widget=BulkEditNullBooleanSelect
+    )
+
+    model = MACPrefix
+    fieldsets = (
+        FieldSet('name', 'enforce_unique', 'description'),
+    )
+    nullable_fields = ('name', 'description', 'comments')

+ 10 - 0
netbox/dcim/forms/bulk_import.py

@@ -55,6 +55,7 @@ __all__ = (
     'InventoryItemRoleImportForm',
     'InventoryItemRoleImportForm',
     'LocationImportForm',
     'LocationImportForm',
     'MACAddressImportForm',
     'MACAddressImportForm',
+    'MACPrefixImportForm',
     'ManufacturerImportForm',
     'ManufacturerImportForm',
     'ModuleBayImportForm',
     'ModuleBayImportForm',
     'ModuleBayTypeImportForm',
     'ModuleBayTypeImportForm',
@@ -1710,6 +1711,15 @@ class MACAddressImportForm(PrimaryModelImportForm):
         return instance
         return instance
 
 
 
 
+class MACPrefixImportForm(PrimaryModelImportForm):
+
+    class Meta:
+        model = MACPrefix
+        fields = [
+            'prefix', 'prefix_length', 'name', 'enforce_unique', 'description', 'owner', 'comments', 'tags',
+        ]
+
+
 #
 #
 # Cables
 # Cables
 #
 #

+ 38 - 1
netbox/dcim/forms/filtersets.py

@@ -60,6 +60,7 @@ __all__ = (
     'InventoryItemTemplateFilterForm',
     'InventoryItemTemplateFilterForm',
     'LocationFilterForm',
     'LocationFilterForm',
     'MACAddressFilterForm',
     'MACAddressFilterForm',
+    'MACPrefixFilterForm',
     'ManufacturerFilterForm',
     'ManufacturerFilterForm',
     'ModuleBayFilterForm',
     'ModuleBayFilterForm',
     'ModuleBayTemplateFilterForm',
     'ModuleBayTemplateFilterForm',
@@ -2452,7 +2453,7 @@ class MACAddressFilterForm(PrimaryModelFilterSetForm):
     model = MACAddress
     model = MACAddress
     fieldsets = (
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('q', 'filter_id', 'tag'),
-        FieldSet('mac_address', name=_('Attributes')),
+        FieldSet('mac_address', 'mac_prefix_id', name=_('Attributes')),
         FieldSet('device_id', 'virtual_machine_id', 'assigned', 'primary', name=_('Assignments')),
         FieldSet('device_id', 'virtual_machine_id', 'assigned', 'primary', name=_('Assignments')),
         FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
         FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
     )
     )
@@ -2461,6 +2462,11 @@ class MACAddressFilterForm(PrimaryModelFilterSetForm):
         required=False,
         required=False,
         label=_('MAC address'),
         label=_('MAC address'),
     )
     )
+    mac_prefix_id = DynamicModelMultipleChoiceField(
+        queryset=MACPrefix.objects.all(),
+        required=False,
+        label=_('MAC prefix'),
+    )
     device_id = DynamicModelMultipleChoiceField(
     device_id = DynamicModelMultipleChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         required=False,
         required=False,
@@ -2488,6 +2494,37 @@ class MACAddressFilterForm(PrimaryModelFilterSetForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
+class MACPrefixFilterForm(PrimaryModelFilterSetForm):
+    model = MACPrefix
+    fieldsets = (
+        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('prefix', 'prefix_length', 'contains', 'enforce_unique', name=_('Attributes')),
+        FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
+    )
+    prefix = forms.CharField(
+        required=False,
+        label=_('Prefix'),
+    )
+    prefix_length = forms.IntegerField(
+        required=False,
+        min_value=MACPREFIX_LENGTH_MIN,
+        max_value=MACPREFIX_LENGTH_MAX,
+        label=_('Prefix length'),
+    )
+    contains = forms.CharField(
+        required=False,
+        label=_('Contains MAC address'),
+    )
+    enforce_unique = forms.NullBooleanField(
+        required=False,
+        label=_('Enforce unique'),
+        widget=forms.Select(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        ),
+    )
+    tag = TagFilterField(model)
+
+
 #
 #
 # Connections
 # Connections
 #
 #

+ 19 - 0
netbox/dcim/forms/model_forms.py

@@ -73,6 +73,7 @@ __all__ = (
     'InventoryItemTemplateForm',
     'InventoryItemTemplateForm',
     'LocationForm',
     'LocationForm',
     'MACAddressForm',
     'MACAddressForm',
+    'MACPrefixForm',
     'ManufacturerForm',
     'ManufacturerForm',
     'ModuleBayForm',
     'ModuleBayForm',
     'ModuleBayTemplateForm',
     'ModuleBayTemplateForm',
@@ -2603,3 +2604,21 @@ class MACAddressForm(PrimaryModelForm):
             self.instance.assigned_object = self.cleaned_data[selected_objects[0]]
             self.instance.assigned_object = self.cleaned_data[selected_objects[0]]
         else:
         else:
             self.instance.assigned_object = None
             self.instance.assigned_object = None
+
+
+class MACPrefixForm(PrimaryModelForm):
+    prefix = forms.CharField(
+        required=True,
+        label=_('Prefix'),
+        help_text=_('The first MAC address in the block (e.g. BC:24:11:00:00:00)')
+    )
+
+    fieldsets = (
+        FieldSet('prefix', 'prefix_length', 'name', 'enforce_unique', 'description', 'tags', name=_('MAC Prefix')),
+    )
+
+    class Meta:
+        model = MACPrefix
+        fields = [
+            'prefix', 'prefix_length', 'name', 'enforce_unique', 'description', 'owner', 'comments', 'tags',
+        ]

+ 11 - 0
netbox/dcim/graphql/filters.py

@@ -97,6 +97,7 @@ __all__ = (
     'InventoryItemTemplateFilter',
     'InventoryItemTemplateFilter',
     'LocationFilter',
     'LocationFilter',
     'MACAddressFilter',
     'MACAddressFilter',
+    'MACPrefixFilter',
     'ManufacturerFilter',
     'ManufacturerFilter',
     'ModuleBayFilter',
     'ModuleBayFilter',
     'ModuleBayTemplateFilter',
     'ModuleBayTemplateFilter',
@@ -538,6 +539,16 @@ class MACAddressFilter(PrimaryModelFilter):
         return ~Q(query)
         return ~Q(query)
 
 
 
 
+@register_filter(models.MACPrefix, lookups=True)
+class MACPrefixFilter(PrimaryModelFilter):
+    prefix: StrFilterLookup | None = strawberry_django.filter_field()
+    prefix_length: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+        strawberry_django.filter_field()
+    )
+    name: StrFilterLookup | None = strawberry_django.filter_field()
+    enforce_unique: FilterLookup[bool] | None = strawberry_django.filter_field()
+
+
 @register_filter(models.Interface, lookups=True)
 @register_filter(models.Interface, lookups=True)
 class InterfaceFilter(
 class InterfaceFilter(
     ModularComponentFilterMixin,
     ModularComponentFilterMixin,

+ 3 - 0
netbox/dcim/graphql/schema.py

@@ -66,6 +66,9 @@ class DCIMQuery:
     mac_address: MACAddressType = strawberry_django.field()
     mac_address: MACAddressType = strawberry_django.field()
     mac_address_list: list[MACAddressType] = strawberry_django.field()
     mac_address_list: list[MACAddressType] = strawberry_django.field()
 
 
+    mac_prefix: MACPrefixType = strawberry_django.field()
+    mac_prefix_list: list[MACPrefixType] = strawberry_django.field()
+
     interface: InterfaceType = strawberry_django.field()
     interface: InterfaceType = strawberry_django.field()
     interface_list: list[InterfaceType] = strawberry_django.field()
     interface_list: list[InterfaceType] = strawberry_django.field()
 
 

+ 11 - 0
netbox/dcim/graphql/types.py

@@ -74,6 +74,7 @@ __all__ = (
     'InventoryItemType',
     'InventoryItemType',
     'LocationType',
     'LocationType',
     'MACAddressType',
     'MACAddressType',
+    'MACPrefixType',
     'ManufacturerType',
     'ManufacturerType',
     'ModularComponentType',
     'ModularComponentType',
     'ModuleBayTemplateType',
     'ModuleBayTemplateType',
@@ -524,6 +525,16 @@ class FrontPortTemplateType(ModularComponentTemplateType):
     mappings: list[Annotated["PortMappingTemplateType", strawberry.lazy('dcim.graphql.types')]]
     mappings: list[Annotated["PortMappingTemplateType", strawberry.lazy('dcim.graphql.types')]]
 
 
 
 
+@register_type(
+    models.MACPrefix,
+    exclude=['_last_address'],
+    filters=MACPrefixFilter,
+    pagination=True
+)
+class MACPrefixType(PrimaryObjectType):
+    prefix: str
+
+
 @register_type(
 @register_type(
     models.MACAddress,
     models.MACAddress,
     exclude=['assigned_object_type', 'assigned_object_id'],
     exclude=['assigned_object_type', 'assigned_object_id'],

+ 77 - 0
netbox/dcim/migrations/0253_macprefix.py

@@ -0,0 +1,77 @@
+# Generated by Django 6.1.1 on 2026-11-11 01:08
+
+import django.core.validators
+import django.db.models.deletion
+import taggit.managers
+from django.db import migrations, models
+
+import dcim.fields
+import netbox.models.deletion
+import utilities.json
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('dcim', '0252_generated_fields'),
+        ('extras', '0145_taggeditem_object_id_bigint'),
+        ('users', '0016_default_ordering_indexes'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='MACPrefix',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('created', models.DateTimeField(auto_now_add=True, null=True)),
+                ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+                (
+                    'custom_field_data',
+                    models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
+                ),
+                ('description', models.CharField(blank=True, max_length=200)),
+                ('comments', models.TextField(blank=True)),
+                ('prefix', dcim.fields.MACAddressField()),
+                (
+                    'prefix_length',
+                    models.PositiveSmallIntegerField(
+                        default=24,
+                        validators=[
+                            django.core.validators.MinValueValidator(1),
+                            django.core.validators.MaxValueValidator(48),
+                        ],
+                    ),
+                ),
+                ('name', models.CharField(blank=True, max_length=100)),
+                ('enforce_unique', models.BooleanField(default=False)),
+                ('_last_address', dcim.fields.MACAddressField(blank=True, editable=False)),
+                (
+                    'owner',
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.PROTECT,
+                        related_name='+',
+                        to='users.owner',
+                    ),
+                ),
+                ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+            ],
+            options={
+                'verbose_name': 'MAC prefix',
+                'verbose_name_plural': 'MAC prefixes',
+                'ordering': ('prefix', 'prefix_length', 'pk'),
+                'indexes': [
+                    models.Index(fields=['prefix', 'prefix_length', 'id'], name='dcim_macpre_prefix_298c1d_idx'),
+                    models.Index(fields=['prefix', '_last_address'], name='dcim_macpre_prefix_fa4f07_idx'),
+                ],
+                'constraints': [
+                    models.UniqueConstraint(
+                        fields=('prefix', 'prefix_length'),
+                        name='dcim_macprefix_unique_prefix_prefix_length',
+                        violation_error_message='A MAC prefix with this prefix and length already exists.',
+                    )
+                ],
+            },
+            bases=(netbox.models.deletion.DeleteMixin, models.Model),
+        ),
+    ]

+ 221 - 2
netbox/dcim/models/devices.py

@@ -9,16 +9,18 @@ from django.core.exceptions import ValidationError
 from django.core.files.storage import default_storage
 from django.core.files.storage import default_storage
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models, router, transaction
 from django.db import models, router, transaction
-from django.db.models import F, ProtectedError, prefetch_related_objects
+from django.db.models import Count, F, ProtectedError, prefetch_related_objects
 from django.db.models.functions import Lower
 from django.db.models.functions import Lower
 from django.db.models.signals import post_save
 from django.db.models.signals import post_save
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
+from netaddr import EUI, AddrFormatError
 
 
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
-from dcim.fields import MACAddressField
+from dcim.fields import MACAddressField, mac_unix_expanded_uppercase
+from dcim.querysets import MACPrefixQuerySet
 from dcim.utils import create_port_mappings, update_interface_bridges, update_interface_parents
 from dcim.utils import create_port_mappings, update_interface_bridges, update_interface_parents
 from extras.models import ConfigContextModel, CustomField
 from extras.models import ConfigContextModel, CustomField
 from extras.querysets import ConfigContextModelQuerySet
 from extras.querysets import ConfigContextModelQuerySet
@@ -42,6 +44,7 @@ __all__ = (
     'DeviceRole',
     'DeviceRole',
     'DeviceType',
     'DeviceType',
     'MACAddress',
     'MACAddress',
+    'MACPrefix',
     'Manufacturer',
     'Manufacturer',
     'Platform',
     'Platform',
     'VirtualChassis',
     'VirtualChassis',
@@ -1559,3 +1562,219 @@ class MACAddress(PrimaryModel):
                     raise ValidationError(
                     raise ValidationError(
                         _("Cannot reassign MAC Address while it is designated as the primary MAC for an object")
                         _("Cannot reassign MAC Address while it is designated as the primary MAC for an object")
                     )
                     )
+
+        # Enforce uniqueness within any MAC prefix which requires it
+        if self.mac_address and (
+            mac_prefix := self.get_mac_prefixes().filter(enforce_unique=True).order_by('-prefix_length').first()
+        ):
+            duplicates = MACAddress.objects.filter(mac_address=self.mac_address).exclude(pk=self.pk)
+            if duplicates.exists():
+                raise ValidationError({
+                    'mac_address': _(
+                        "Duplicate MAC address found within MAC prefix {mac_prefix}, which enforces uniqueness"
+                    ).format(mac_prefix=mac_prefix)
+                })
+
+    def get_mac_prefixes(self):
+        """
+        Return all MACPrefixes which contain this MAC address.
+        """
+        return MACPrefix.objects.filter(prefix__lte=self.mac_address, _last_address__gte=self.mac_address)
+
+    @cached_property
+    def mac_prefix(self):
+        """
+        Return the most specific MACPrefix which contains this MAC address (if any).
+        """
+        if not self.mac_address:
+            return None
+        return self.get_mac_prefixes().order_by('-prefix_length').first()
+
+
+class MACPrefix(PrimaryModel):
+    """
+    A block of MAC addresses which share a common prefix, such as an organizationally unique identifier (OUI).
+    """
+    prefix = MACAddressField(
+        verbose_name=_('prefix'),
+        help_text=_('The first MAC address in the block')
+    )
+    prefix_length = models.PositiveSmallIntegerField(
+        verbose_name=_('prefix length'),
+        default=MACPREFIX_LENGTH_DEFAULT,
+        validators=(
+            MinValueValidator(MACPREFIX_LENGTH_MIN),
+            MaxValueValidator(MACPREFIX_LENGTH_MAX),
+        ),
+        help_text=_('Number of leading bits shared by all MAC addresses in the block (e.g. 24 for an OUI)')
+    )
+    name = models.CharField(
+        verbose_name=_('name'),
+        max_length=100,
+        blank=True,
+        help_text=_('For example, the organization to which the block is assigned')
+    )
+    enforce_unique = models.BooleanField(
+        verbose_name=_('enforce unique'),
+        default=False,
+        help_text=_('Prevent duplicate MAC addresses within this prefix')
+    )
+
+    # Cached last MAC address in the block, for finding the MAC addresses and prefixes it contains
+    _last_address = MACAddressField(
+        blank=True,
+        editable=False
+    )
+
+    objects = MACPrefixQuerySet.as_manager()
+
+    clone_fields = ('prefix_length', 'enforce_unique', 'description')
+
+    class Meta:
+        ordering = ('prefix', 'prefix_length', 'pk')
+        indexes = (
+            models.Index(fields=('prefix', 'prefix_length', 'id')),  # Default ordering
+            models.Index(fields=('prefix', '_last_address')),
+        )
+        constraints = (
+            models.UniqueConstraint(
+                fields=('prefix', 'prefix_length'),
+                name='%(app_label)s_%(class)s_unique_prefix_prefix_length',
+                violation_error_message=_("A MAC prefix with this prefix and length already exists.")
+            ),
+        )
+        verbose_name = _('MAC prefix')
+        verbose_name_plural = _('MAC prefixes')
+
+    def __str__(self):
+        return f'{self.prefix}/{self.prefix_length}'
+
+    def _get_bounds(self):
+        """
+        Return the first and last MAC addresses in the block as integers, or None if the prefix or its length is
+        invalid.
+        """
+        try:
+            prefix = int(EUI(self.prefix, version=48))
+            prefix_length = int(self.prefix_length)
+        except (AddrFormatError, TypeError, ValueError):
+            return None
+        if not MACPREFIX_LENGTH_MIN <= prefix_length <= MACPREFIX_LENGTH_MAX:
+            return None
+        host_mask = (1 << (48 - prefix_length)) - 1
+        return prefix & ~host_mask, prefix | host_mask
+
+    @property
+    def first_address(self):
+        """
+        Return the first MAC address in the block (the prefix with any host bits cleared).
+        """
+        if bounds := self._get_bounds():
+            return EUI(bounds[0], version=48, dialect=mac_unix_expanded_uppercase)
+        return None
+
+    @property
+    def last_address(self):
+        """
+        Return the last MAC address in the block.
+        """
+        if bounds := self._get_bounds():
+            return EUI(bounds[1], version=48, dialect=mac_unix_expanded_uppercase)
+        return None
+
+    @property
+    def size(self):
+        """
+        Return the number of MAC addresses in the block.
+        """
+        return 2 ** (48 - self.prefix_length)
+
+    def clean(self):
+        super().clean()
+
+        # Uniqueness cannot be enforced if the prefix already contains duplicate MAC addresses
+        if self.enforce_unique and self.first_address is not None:
+            duplicate = self.get_mac_addresses().values('mac_address').annotate(
+                count=Count('pk')
+            ).filter(count__gt=1).order_by('mac_address').first()
+            if duplicate:
+                raise ValidationError({
+                    'enforce_unique': _(
+                        "Cannot enforce uniqueness: duplicate MAC address {mac_address} exists within this prefix"
+                    ).format(mac_address=duplicate['mac_address'])
+                })
+
+    def save(self, *args, **kwargs):
+        # Clear any host bits from the prefix, and cache the last address in the block
+        self.prefix = self.first_address
+        self._last_address = self.last_address
+
+        super().save(*args, **kwargs)
+
+    def get_mac_addresses(self):
+        """
+        Return all MACAddresses within this prefix.
+        """
+        return MACAddress.objects.filter(mac_address__gte=self.first_address, mac_address__lte=self.last_address)
+
+    def get_child_prefixes(self):
+        """
+        Return all MACPrefixes within this prefix.
+        """
+        return MACPrefix.objects.filter(
+            prefix__gte=self.first_address,
+            _last_address__lte=self.last_address,
+            prefix_length__gt=self.prefix_length,
+        )
+
+    @cached_property
+    def mac_address_count(self):
+        """
+        Return the number of distinct MAC addresses within the prefix. (May be annotated by
+        MACPrefixQuerySet.annotate_mac_address_count().)
+        """
+        return self.get_mac_addresses().order_by().values('mac_address').distinct().count()
+
+    @property
+    def utilization(self):
+        """
+        Return the percentage of MAC addresses within the prefix which exist in NetBox.
+        """
+        return self.mac_address_count / self.size * 100
+
+    def _iter_occupied(self, batch_size=5000):
+        """
+        Yield the distinct MAC addresses within the prefix which exist in NetBox as integers, in ascending order.
+        Addresses are fetched in batches, so that stopping early stops reading from the database.
+        """
+        resume = self.first_address
+        while True:
+            mac_addresses = list(
+                MACAddress.objects.filter(mac_address__gte=resume, mac_address__lte=self.last_address)
+                .order_by('mac_address')
+                .values_list('mac_address', flat=True)
+                .distinct()[:batch_size]
+            )
+            for mac_address in mac_addresses:
+                yield int(mac_address)
+            if len(mac_addresses) < batch_size or mac_addresses[-1] == self.last_address:
+                return
+            resume = EUI(int(mac_addresses[-1]) + 1, version=48)
+
+    def iter_available_mac_addresses(self):
+        """
+        Yield the MAC addresses within the prefix which do not exist in NetBox, in ascending order.
+        """
+        candidate = int(self.first_address)
+        for occupied in self._iter_occupied():
+            for value in range(candidate, occupied):
+                yield EUI(value, version=48, dialect=mac_unix_expanded_uppercase)
+            candidate = occupied + 1
+        for value in range(candidate, int(self.last_address) + 1):
+            yield EUI(value, version=48, dialect=mac_unix_expanded_uppercase)
+
+    def get_first_available_mac_address(self):
+        """
+        Return the first MAC address within the prefix which does not exist in NetBox (or None).
+        """
+        return next(self.iter_available_mac_addresses(), None)

+ 30 - 0
netbox/dcim/querysets.py

@@ -0,0 +1,30 @@
+from django.db.models import Count, IntegerField, OuterRef, Subquery, Value
+from django.db.models.functions import Coalesce
+
+from utilities.querysets import RestrictedQuerySet
+
+__all__ = (
+    'MACPrefixQuerySet',
+)
+
+
+class MACPrefixQuerySet(RestrictedQuerySet):
+
+    def annotate_mac_address_count(self):
+        """
+        Annotate the number of distinct MAC addresses within each MACPrefix.
+        """
+        from .models import MACAddress
+
+        mac_addresses = MACAddress.objects.filter(
+            mac_address__gte=OuterRef('prefix'),
+            mac_address__lte=OuterRef('_last_address'),
+        ).order_by().annotate(
+            group=Value(1)
+        ).values('group').annotate(
+            count=Count('mac_address', distinct=True)
+        ).values('count')
+
+        return self.annotate(
+            mac_address_count=Coalesce(Subquery(mac_addresses, output_field=IntegerField()), 0)
+        )

+ 12 - 0
netbox/dcim/search.py

@@ -167,6 +167,18 @@ class MACAddressIndex(SearchIndex):
     display_attrs = ('assigned_object', 'description')
     display_attrs = ('assigned_object', 'description')
 
 
 
 
+@register_search
+class MACPrefixIndex(SearchIndex):
+    model = models.MACPrefix
+    fields = (
+        ('prefix', 100),
+        ('name', 100),
+        ('description', 500),
+        ('comments', 5000),
+    )
+    display_attrs = ('name', 'description')
+
+
 @register_search
 @register_search
 class InterfaceIndex(SearchIndex):
 class InterfaceIndex(SearchIndex):
     model = models.Interface
     model = models.Interface

+ 51 - 0
netbox/dcim/tables/devices.py

@@ -38,6 +38,7 @@ __all__ = (
     'InventoryItemRoleTable',
     'InventoryItemRoleTable',
     'InventoryItemTable',
     'InventoryItemTable',
     'MACAddressTable',
     'MACAddressTable',
+    'MACPrefixTable',
     'ModuleBayTable',
     'ModuleBayTable',
     'PlatformTable',
     'PlatformTable',
     'PowerOutletTable',
     'PowerOutletTable',
@@ -1330,3 +1331,53 @@ class MACAddressTable(PrimaryModelTable):
         default_columns = (
         default_columns = (
             'pk', 'mac_address', 'is_primary', 'assigned_object_parent', 'assigned_object', 'description',
             'pk', 'mac_address', 'is_primary', 'assigned_object_parent', 'assigned_object', 'description',
         )
         )
+
+
+class MACPrefixTable(PrimaryModelTable):
+    prefix = tables.Column(
+        verbose_name=_('Prefix'),
+        linkify=True
+    )
+    prefix_length = tables.Column(
+        verbose_name=_('Length')
+    )
+    name = tables.Column(
+        verbose_name=_('Name')
+    )
+    size = tables.Column(
+        verbose_name=_('Size'),
+        orderable=False
+    )
+    mac_address_count = columns.LinkedCountColumn(
+        viewname='dcim:macaddress_list',
+        url_params={'mac_prefix_id': 'pk'},
+        verbose_name=_('MAC Addresses'),
+        orderable=False
+    )
+    utilization = columns.UtilizationColumn(
+        verbose_name=_('Utilization'),
+        orderable=False
+    )
+    enforce_unique = columns.BooleanColumn(
+        verbose_name=_('Enforce Unique')
+    )
+    tags = columns.TagColumn(
+        url_name='dcim:macprefix_list'
+    )
+
+    def render_prefix(self, record):
+        # Include the prefix length, to distinguish blocks which start at the same address
+        return str(record)
+
+    def value_prefix(self, record):
+        return str(record.prefix)
+
+    class Meta(PrimaryModelTable.Meta):
+        model = models.MACPrefix
+        fields = (
+            'pk', 'id', 'prefix', 'prefix_length', 'name', 'size', 'mac_address_count', 'utilization',
+            'enforce_unique', 'description', 'comments', 'tags', 'created', 'last_updated',
+        )
+        default_columns = (
+            'pk', 'prefix', 'name', 'mac_address_count', 'utilization', 'enforce_unique', 'description',
+        )

+ 2 - 0
netbox/dcim/tests/query_counts.json

@@ -46,6 +46,8 @@
   "location:list_objects_with_permission": 18,
   "location:list_objects_with_permission": 18,
   "macaddress:api_list_objects": 16,
   "macaddress:api_list_objects": 16,
   "macaddress:list_objects_with_permission": 20,
   "macaddress:list_objects_with_permission": 20,
+  "macprefix:api_list_objects": 12,
+  "macprefix:list_objects_with_permission": 16,
   "manufacturer:api_list_objects": 12,
   "manufacturer:api_list_objects": 12,
   "manufacturer:list_objects_with_permission": 16,
   "manufacturer:list_objects_with_permission": 16,
   "module:api_list_objects": 19,
   "module:api_list_objects": 19,

+ 106 - 0
netbox/dcim/tests/test_api.py

@@ -32,6 +32,7 @@ from extras.models import ConfigContext, ConfigTemplate, ExportTemplate, Tag
 from ipam.choices import FHRPGroupProtocolChoices, VLANQinQRoleChoices
 from ipam.choices import FHRPGroupProtocolChoices, VLANQinQRoleChoices
 from ipam.models import ASN, RIR, VLAN, VRF, FHRPGroup, FHRPGroupAssignment, IPAddress
 from ipam.models import ASN, RIR, VLAN, VRF, FHRPGroup, FHRPGroupAssignment, IPAddress
 from netbox.api.serializers import GenericObjectSerializer
 from netbox.api.serializers import GenericObjectSerializer
+from netbox.config import get_config
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from users.constants import TOKEN_PREFIX
 from users.constants import TOKEN_PREFIX
 from users.models import ObjectPermission, Token, User
 from users.models import ObjectPermission, Token, User
@@ -7400,3 +7401,108 @@ class MACAddressTestCase(APIViewTestCases.APIViewTestCase):
         url = reverse('dcim-api:macaddress-detail', kwargs={'pk': non_primary_mac.pk})
         url = reverse('dcim-api:macaddress-detail', kwargs={'pk': non_primary_mac.pk})
         response = self.client.get(url, **self.header)
         response = self.client.get(url, **self.header)
         self.assertFalse(response.data['is_primary'])
         self.assertFalse(response.data['is_primary'])
+
+
+class MACPrefixTestCase(APIViewTestCases.APIViewTestCase):
+    model = MACPrefix
+    brief_fields = ['description', 'display', 'id', 'name', 'prefix', 'prefix_length', 'url']
+    bulk_update_data = {
+        'description': 'New description',
+    }
+    bulk_update_invalid_data = {
+        'prefix_length': 99,
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+        MACPrefix.objects.create(prefix='BC:24:11:00:00:00', prefix_length=24, name='Prefix 1')
+        MACPrefix.objects.create(prefix='BC:24:12:00:00:00', prefix_length=24, name='Prefix 2')
+        MACPrefix.objects.create(prefix='BC:24:13:00:00:00', prefix_length=24, name='Prefix 3')
+
+        cls.create_data = [
+            {
+                'prefix': 'BC:24:14:00:00:00',
+                'prefix_length': 24,
+                'name': 'Prefix 4',
+            },
+            {
+                'prefix': 'BC:24:14:50:00:00',
+                'prefix_length': 28,
+                'enforce_unique': True,
+            },
+            {
+                'prefix': 'BC:24:15:00:00:00',
+            },
+        ]
+
+    def test_mac_address_count(self):
+        self.add_permissions('dcim.view_macprefix')
+        mac_prefix = MACPrefix.objects.get(name='Prefix 1')
+        MACAddress.objects.bulk_create((
+            MACAddress(mac_address='BC:24:11:00:00:01'),
+            MACAddress(mac_address='BC:24:11:00:00:01'),
+            MACAddress(mac_address='BC:24:11:00:00:02'),
+        ))
+        response = self.client.get(self._get_detail_url(mac_prefix), **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data['mac_address_count'], 2)
+
+    def test_list_available_mac_addresses(self):
+        mac_prefix = MACPrefix.objects.create(prefix='BC:24:16:00:00:00', prefix_length=45)
+        MACAddress.objects.create(mac_address='BC:24:16:00:00:01')
+        url = reverse('dcim-api:macprefix-available-mac-addresses', kwargs={'pk': mac_prefix.pk})
+        self.add_permissions('dcim.view_macprefix', 'dcim.view_macaddress')
+
+        response = self.client.get(url, **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(
+            [mac['mac_address'] for mac in response.data],
+            ['BC:24:16:00:00:00', 'BC:24:16:00:00:02', 'BC:24:16:00:00:03', 'BC:24:16:00:00:04',
+             'BC:24:16:00:00:05', 'BC:24:16:00:00:06', 'BC:24:16:00:00:07']
+        )
+
+        # The number of results is limited
+        response = self.client.get(url, {'limit': 2}, **self.header)
+        self.assertEqual(len(response.data), 2)
+
+        # A large prefix returns a single page of results
+        mac_prefix = MACPrefix.objects.get(name='Prefix 1')
+        url = reverse('dcim-api:macprefix-available-mac-addresses', kwargs={'pk': mac_prefix.pk})
+        response = self.client.get(url, **self.header)
+        self.assertEqual(len(response.data), get_config().PAGINATE_COUNT)
+
+    def test_create_single_available_mac_address(self):
+        mac_prefix = MACPrefix.objects.create(prefix='BC:24:16:00:00:00', prefix_length=47)
+        url = reverse('dcim-api:macprefix-available-mac-addresses', kwargs={'pk': mac_prefix.pk})
+        self.add_permissions('dcim.view_macprefix', 'dcim.add_macaddress')
+
+        # Create both available MAC addresses with individual requests
+        for i in range(2):
+            data = {'description': f'MAC address {i}'}
+            response = self.client.post(url, data, format='json', **self.header)
+            self.assertHttpStatus(response, status.HTTP_201_CREATED)
+            self.assertEqual(response.data['mac_address'], f'BC:24:16:00:00:0{i}')
+            self.assertEqual(response.data['description'], data['description'])
+
+        # Try to create one more MAC address
+        response = self.client.post(url, {}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
+        self.assertIn('detail', response.data)
+
+    def test_create_multiple_available_mac_addresses(self):
+        mac_prefix = MACPrefix.objects.create(prefix='BC:24:16:00:00:00', prefix_length=46)
+        url = reverse('dcim-api:macprefix-available-mac-addresses', kwargs={'pk': mac_prefix.pk})
+        self.add_permissions('dcim.view_macprefix', 'dcim.add_macaddress')
+
+        # Try to create five MAC addresses (only four are available)
+        data = [{'description': f'MAC address {i}'} for i in range(5)]
+        response = self.client.post(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
+
+        # Create all four available MAC addresses in a single request
+        response = self.client.post(url, data[:4], format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(
+            [mac['mac_address'] for mac in response.data],
+            ['BC:24:16:00:00:00', 'BC:24:16:00:00:01', 'BC:24:16:00:00:02', 'BC:24:16:00:00:03']
+        )

+ 67 - 0
netbox/dcim/tests/test_filtersets.py

@@ -9170,3 +9170,70 @@ class MACAddressTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
         params = {'primary': False}
         params = {'primary': False}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
+    def test_mac_prefix(self):
+        mac_prefixes = (
+            MACPrefix.objects.create(prefix='00:00:00:01:00:00', prefix_length=32),
+            MACPrefix.objects.create(prefix='00:00:00:03:00:00', prefix_length=32),
+        )
+        params = {'mac_prefix_id': [mac_prefixes[0].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'mac_prefix_id': [mac_prefixes[0].pk, mac_prefixes[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
+
+class MACPrefixTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
+    queryset = MACPrefix.objects.all()
+    filterset = MACPrefixFilterSet
+    ignore_fields = ('_last_address',)
+
+    @classmethod
+    def setUpTestData(cls):
+        MACPrefix.objects.create(
+            prefix='BC:24:11:00:00:00', prefix_length=24, name='Prefix 1', enforce_unique=True,
+            description='foobar1'
+        )
+        MACPrefix.objects.create(
+            prefix='BC:24:11:50:00:00', prefix_length=28, name='Prefix 2', description='foobar2'
+        )
+        MACPrefix.objects.create(
+            prefix='BC:24:12:00:00:00', prefix_length=36, name='Prefix 3', description='foobar3'
+        )
+
+    def test_q(self):
+        params = {'q': 'Prefix 1'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'q': 'BC:24:11'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_prefix(self):
+        params = {'prefix': ['BC:24:11:00:00:00', 'bc-24-12-00-00-00']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_prefix_length(self):
+        params = {'prefix_length': [24, 28]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_name(self):
+        params = {'name': ['Prefix 1', 'Prefix 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_enforce_unique(self):
+        params = {'enforce_unique': True}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'enforce_unique': False}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_description(self):
+        params = {'description': ['foobar1', 'foobar2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_contains(self):
+        params = {'contains': 'BC:24:11:51:00:00'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'contains': 'BC:24:11:61:00:00'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'contains': 'BC:24:13:00:00:00'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
+        params = {'contains': 'invalid'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)

+ 138 - 0
netbox/dcim/tests/test_models.py

@@ -11,6 +11,7 @@ from django.db.models.signals import post_save
 from django.test import RequestFactory, TestCase, override_settings, tag
 from django.test import RequestFactory, TestCase, override_settings, tag
 from django.test.utils import CaptureQueriesContext
 from django.test.utils import CaptureQueriesContext
 from django.utils.text import slugify
 from django.utils.text import slugify
+from netaddr import EUI
 
 
 from circuits.models import *
 from circuits.models import *
 from core.choices import ObjectChangeActionChoices
 from core.choices import ObjectChangeActionChoices
@@ -129,6 +130,143 @@ class MACAddressTestCase(TestCase):
         new_iface.full_clean()  # must not raise
         new_iface.full_clean()  # must not raise
 
 
 
 
+class MACPrefixTestCase(TestCase):
+
+    def test_prefix_normalized(self):
+        mac_prefix = MACPrefix.objects.create(prefix='BC:24:11:12:34:56', prefix_length=24)
+        mac_prefix.refresh_from_db()
+        self.assertEqual(str(mac_prefix.prefix), 'BC:24:11:00:00:00')
+        self.assertEqual(str(mac_prefix.last_address), 'BC:24:11:FF:FF:FF')
+        self.assertEqual(str(mac_prefix._last_address), 'BC:24:11:FF:FF:FF')
+        self.assertEqual(str(mac_prefix), 'BC:24:11:00:00:00/24')
+
+    def test_invalid_prefix(self):
+        for prefix, prefix_length in (('invalid', 24), ('BC:24:11:00:00:00', 49), ('BC:24:11:00:00:00', 'x')):
+            mac_prefix = MACPrefix(prefix=prefix, prefix_length=prefix_length, enforce_unique=True)
+            self.assertIsNone(mac_prefix.first_address)
+            with self.assertRaises(ValidationError):
+                mac_prefix.full_clean()
+
+    def test_size(self):
+        self.assertEqual(MACPrefix(prefix='BC:24:11:00:00:00', prefix_length=24).size, 2 ** 24)
+        self.assertEqual(MACPrefix(prefix='BC:24:11:50:00:00', prefix_length=28).size, 2 ** 20)
+        self.assertEqual(MACPrefix(prefix='BC:24:11:00:00:01', prefix_length=48).size, 1)
+
+    def test_duplicate_prefix(self):
+        MACPrefix.objects.create(prefix='BC:24:11:00:00:00', prefix_length=24)
+        mac_prefix = MACPrefix(prefix='BC:24:11:00:00:00', prefix_length=24)
+        with self.assertRaises(ValidationError):
+            mac_prefix.full_clean()
+        # A prefix of a different length may share the same first address
+        MACPrefix(prefix='BC:24:11:00:00:00', prefix_length=28).full_clean()
+
+    def test_get_mac_addresses(self):
+        mac_prefix = MACPrefix.objects.create(prefix='BC:24:11:50:00:00', prefix_length=28)
+        MACAddress.objects.bulk_create((
+            MACAddress(mac_address='BC:24:11:4F:FF:FF'),
+            MACAddress(mac_address='BC:24:11:50:00:00'),
+            MACAddress(mac_address='BC:24:11:5F:FF:FF'),
+            MACAddress(mac_address='BC:24:11:60:00:00'),
+        ))
+        self.assertEqual(
+            sorted(str(mac.mac_address) for mac in mac_prefix.get_mac_addresses()),
+            ['BC:24:11:50:00:00', 'BC:24:11:5F:FF:FF']
+        )
+
+    def test_get_child_prefixes(self):
+        mac_prefixes = (
+            MACPrefix.objects.create(prefix='BC:24:11:00:00:00', prefix_length=24),
+            MACPrefix.objects.create(prefix='BC:24:11:00:00:00', prefix_length=28),
+            MACPrefix.objects.create(prefix='BC:24:11:F0:00:00', prefix_length=28),
+            MACPrefix.objects.create(prefix='BC:24:12:00:00:00', prefix_length=28),
+        )
+        self.assertEqual(list(mac_prefixes[0].get_child_prefixes()), [mac_prefixes[1], mac_prefixes[2]])
+        self.assertEqual(list(mac_prefixes[1].get_child_prefixes()), [])
+
+    def test_mac_address_mac_prefix(self):
+        mac_prefixes = (
+            MACPrefix.objects.create(prefix='BC:24:11:00:00:00', prefix_length=24),
+            MACPrefix.objects.create(prefix='BC:24:11:50:00:00', prefix_length=28),
+        )
+        self.assertEqual(MACAddress(mac_address='BC:24:11:51:00:00').mac_prefix, mac_prefixes[1])
+        self.assertEqual(MACAddress(mac_address='BC:24:11:61:00:00').mac_prefix, mac_prefixes[0])
+        self.assertIsNone(MACAddress(mac_address='BC:24:12:00:00:00').mac_prefix)
+
+    def test_available_mac_addresses(self):
+        mac_prefix = MACPrefix.objects.create(prefix='BC:24:11:00:00:00', prefix_length=45)
+        self.assertEqual(str(mac_prefix.get_first_available_mac_address()), 'BC:24:11:00:00:00')
+
+        MACAddress.objects.bulk_create((
+            MACAddress(mac_address='BC:24:11:00:00:00'),
+            MACAddress(mac_address='BC:24:11:00:00:01'),
+            MACAddress(mac_address='BC:24:11:00:00:01'),  # Duplicate
+            MACAddress(mac_address='BC:24:11:00:00:03'),
+            MACAddress(mac_address='BC:24:11:00:00:07'),
+            MACAddress(mac_address='BC:24:11:00:00:08'),  # Outside the prefix
+        ))
+        self.assertEqual(str(mac_prefix.get_first_available_mac_address()), 'BC:24:11:00:00:02')
+        self.assertEqual(
+            [str(mac) for mac in mac_prefix.iter_available_mac_addresses()],
+            ['BC:24:11:00:00:02', 'BC:24:11:00:00:04', 'BC:24:11:00:00:05', 'BC:24:11:00:00:06']
+        )
+
+        # Fetching occupied addresses in batches finds the same gaps
+        self.assertEqual(
+            list(mac_prefix._iter_occupied(batch_size=2)),
+            [int(EUI(mac)) for mac in ('BC:24:11:00:00:00', 'BC:24:11:00:00:01', 'BC:24:11:00:00:03',
+                                       'BC:24:11:00:00:07')]
+        )
+
+    def test_no_available_mac_addresses(self):
+        mac_prefix = MACPrefix.objects.create(prefix='BC:24:11:00:00:00', prefix_length=47)
+        MACAddress.objects.bulk_create((
+            MACAddress(mac_address='BC:24:11:00:00:00'),
+            MACAddress(mac_address='BC:24:11:00:00:01'),
+        ))
+        self.assertIsNone(mac_prefix.get_first_available_mac_address())
+        self.assertEqual(mac_prefix.utilization, 100)
+
+    def test_utilization(self):
+        mac_prefix = MACPrefix.objects.create(prefix='BC:24:11:00:00:00', prefix_length=46)
+        MACAddress.objects.bulk_create((
+            MACAddress(mac_address='BC:24:11:00:00:00'),
+            MACAddress(mac_address='BC:24:11:00:00:00'),  # Duplicate
+            MACAddress(mac_address='BC:24:11:00:00:02'),
+        ))
+        self.assertEqual(mac_prefix.utilization, 50)
+        annotated = MACPrefix.objects.annotate_mac_address_count().get(pk=mac_prefix.pk)
+        self.assertEqual(annotated.mac_address_count, 2)
+        self.assertEqual(annotated.utilization, 50)
+
+    def test_enforce_unique(self):
+        MACPrefix.objects.create(prefix='BC:24:11:00:00:00', prefix_length=24, enforce_unique=True)
+        MACAddress.objects.create(mac_address='BC:24:11:00:00:01')
+        MACAddress.objects.create(mac_address='BC:24:12:00:00:01')
+
+        # A duplicate within the prefix is rejected
+        with self.assertRaisesMessage(ValidationError, 'BC:24:11:00:00:00/24'):
+            MACAddress(mac_address='BC:24:11:00:00:01').full_clean()
+
+        # A duplicate outside the prefix is permitted
+        MACAddress(mac_address='BC:24:12:00:00:01').full_clean()
+
+        # Saving an existing MAC address does not count it as its own duplicate
+        MACAddress.objects.get(mac_address='BC:24:11:00:00:01').full_clean()
+
+    def test_enforce_unique_with_duplicates(self):
+        MACAddress.objects.bulk_create((
+            MACAddress(mac_address='BC:24:11:00:00:01'),
+            MACAddress(mac_address='BC:24:11:00:00:01'),
+        ))
+        mac_prefix = MACPrefix(prefix='BC:24:11:00:00:00', prefix_length=24, enforce_unique=True)
+        with self.assertRaisesMessage(ValidationError, 'BC:24:11:00:00:01'):
+            mac_prefix.full_clean()
+
+        # Uniqueness is not enforced by default
+        mac_prefix.enforce_unique = False
+        mac_prefix.full_clean()
+
+
 class LocationTestCase(TestCase):
 class LocationTestCase(TestCase):
 
 
     def test_change_location_site(self):
     def test_change_location_site(self):

+ 4 - 0
netbox/dcim/tests/test_tables.py

@@ -334,3 +334,7 @@ class VirtualDeviceContextTableTestCase(TableTestCases.StandardTableTestCase):
 
 
 class MACAddressTableTestCase(TableTestCases.StandardTableTestCase):
 class MACAddressTableTestCase(TableTestCases.StandardTableTestCase):
     table = MACAddressTable
     table = MACAddressTable
+
+
+class MACPrefixTableTestCase(TableTestCases.StandardTableTestCase):
+    table = MACPrefixTable

+ 55 - 0
netbox/dcim/tests/test_views.py

@@ -6971,3 +6971,58 @@ class MACAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         self.assertHttpStatus(response, 200)
         self.assertHttpStatus(response, 200)
         self.assertIn(b'quick-add-object', response.content)
         self.assertIn(b'quick-add-object', response.content)
         self.assertEqual(initial_count + 1, self._get_queryset().count())
         self.assertEqual(initial_count + 1, self._get_queryset().count())
+
+
+class MACPrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+    model = MACPrefix
+
+    @classmethod
+    def setUpTestData(cls):
+        mac_prefixes = (
+            MACPrefix.objects.create(prefix='BC:24:11:00:00:00', prefix_length=24, name='Prefix 1'),
+            MACPrefix.objects.create(prefix='BC:24:12:00:00:00', prefix_length=24, name='Prefix 2'),
+            MACPrefix.objects.create(prefix='BC:24:13:00:00:00', prefix_length=24, name='Prefix 3'),
+        )
+
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+        cls.form_data = {
+            'prefix': EUI('BC:24:14:50:00:00'),
+            'prefix_length': 28,
+            'name': 'Prefix X',
+            'enforce_unique': True,
+            'description': 'New MAC prefix',
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.csv_data = (
+            "prefix,prefix_length,name",
+            "BC:24:15:00:00:00,24,Prefix 4",
+            "BC:24:16:00:00:00,28,Prefix 5",
+            "BC:24:17:00:00:00,36,Prefix 6",
+        )
+
+        cls.csv_update_data = (
+            "id,name,enforce_unique",
+            f"{mac_prefixes[0].pk},Prefix 7,true",
+            f"{mac_prefixes[1].pk},Prefix 8,true",
+            f"{mac_prefixes[2].pk},Prefix 9,false",
+        )
+
+        cls.bulk_edit_data = {
+            'name': 'New name',
+            'enforce_unique': True,
+            'description': 'New description',
+        }
+
+    def test_mac_addresses_panel(self):
+        """
+        The MAC prefix page offers to add the first available MAC address within the prefix.
+        """
+        self.add_permissions('dcim.view_macprefix', 'dcim.view_macaddress', 'dcim.add_macaddress')
+        mac_prefix = MACPrefix.objects.get(name='Prefix 1')
+        MACAddress.objects.create(mac_address='BC:24:11:00:00:00')
+
+        response = self.client.get(mac_prefix.get_absolute_url())
+        self.assertHttpStatus(response, 200)
+        self.assertContains(response, 'mac_address=BC%3A24%3A11%3A00%3A00%3A01')

+ 12 - 0
netbox/dcim/ui/panels.py

@@ -476,6 +476,18 @@ class MACAddressPanel(panels.ObjectAttributesPanel):
     description = attrs.TextAttr('description')
     description = attrs.TextAttr('description')
     assignment = attrs.RelatedObjectAttr('assigned_object', linkify=True, grouped_by='parent_object')
     assignment = attrs.RelatedObjectAttr('assigned_object', linkify=True, grouped_by='parent_object')
     is_primary = attrs.BooleanAttr('is_primary', label=_('Primary for interface'))
     is_primary = attrs.BooleanAttr('is_primary', label=_('Primary for interface'))
+    mac_prefix = attrs.RelatedObjectAttr('mac_prefix', linkify=True, label=_('MAC prefix'))
+
+
+class MACPrefixPanel(panels.ObjectAttributesPanel):
+    prefix = attrs.TextAttr('prefix', label=_('Prefix'), style='font-monospace', copy_button=True)
+    prefix_length = attrs.NumericAttr('prefix_length', label=_('Prefix length'))
+    name = attrs.TextAttr('name')
+    last_address = attrs.TextAttr('last_address', label=_('Last address'), style='font-monospace')
+    size = attrs.NumericAttr('size')
+    utilization = attrs.UtilizationAttr('utilization')
+    enforce_unique = attrs.BooleanAttr('enforce_unique', label=_('Enforce unique'))
+    description = attrs.TextAttr('description')
 
 
 
 
 class ConnectionPanel(panels.ObjectPanel):
 class ConnectionPanel(panels.ObjectPanel):

+ 2 - 0
netbox/dcim/urls.py

@@ -208,5 +208,7 @@ urlpatterns = [
 
 
     path('mac-addresses/', include(get_model_urls('dcim', 'macaddress', detail=False))),
     path('mac-addresses/', include(get_model_urls('dcim', 'macaddress', detail=False))),
     path('mac-addresses/<int:pk>/', include(get_model_urls('dcim', 'macaddress'))),
     path('mac-addresses/<int:pk>/', include(get_model_urls('dcim', 'macaddress'))),
+    path('mac-prefixes/', include(get_model_urls('dcim', 'macprefix', detail=False))),
+    path('mac-prefixes/<int:pk>/', include(get_model_urls('dcim', 'macprefix'))),
 
 
 ]
 ]

+ 76 - 0
netbox/dcim/views.py

@@ -5947,3 +5947,79 @@ class MACAddressBulkDeleteView(generic.BulkDeleteView):
     queryset = MACAddress.objects.all()
     queryset = MACAddress.objects.all()
     filterset = filtersets.MACAddressFilterSet
     filterset = filtersets.MACAddressFilterSet
     table = tables.MACAddressTable
     table = tables.MACAddressTable
+
+
+#
+# MAC prefixes
+#
+
+@register_model_view(MACPrefix, 'list', path='', detail=False)
+class MACPrefixListView(generic.ObjectListView):
+    queryset = MACPrefix.objects.annotate_mac_address_count()
+    filterset = filtersets.MACPrefixFilterSet
+    filterset_form = forms.MACPrefixFilterForm
+    table = tables.MACPrefixTable
+    actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
+
+
+@register_model_view(MACPrefix)
+class MACPrefixView(generic.ObjectView):
+    queryset = MACPrefix.objects.all()
+    template_name = 'generic/object.html'
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.MACPrefixPanel(),
+            TagsPanel(),
+            CustomFieldsPanel(),
+        ],
+        right_panels=[
+            CommentsPanel(),
+        ],
+        bottom_panels=[
+            ObjectsTablePanel(
+                model='dcim.MACAddress',
+                title=_('MAC Addresses'),
+                filters={'mac_prefix_id': lambda ctx: ctx['object'].pk},
+                actions=[
+                    actions.AddObject(
+                        'dcim.MACAddress',
+                        label=_('Add Next Available'),
+                        url_params={'mac_address': lambda ctx: ctx['object'].get_first_available_mac_address()},
+                    ),
+                ],
+            ),
+        ],
+    )
+
+
+@register_model_view(MACPrefix, 'add', detail=False)
+@register_model_view(MACPrefix, 'edit')
+class MACPrefixEditView(generic.ObjectEditView):
+    queryset = MACPrefix.objects.all()
+    form = forms.MACPrefixForm
+
+
+@register_model_view(MACPrefix, 'delete')
+class MACPrefixDeleteView(generic.ObjectDeleteView):
+    queryset = MACPrefix.objects.all()
+
+
+@register_model_view(MACPrefix, 'bulk_import', path='import', detail=False)
+class MACPrefixBulkImportView(generic.BulkImportView):
+    queryset = MACPrefix.objects.all()
+    model_form = forms.MACPrefixImportForm
+
+
+@register_model_view(MACPrefix, 'bulk_edit', path='edit', detail=False)
+class MACPrefixBulkEditView(generic.BulkEditView):
+    queryset = MACPrefix.objects.annotate_mac_address_count()
+    filterset = filtersets.MACPrefixFilterSet
+    table = tables.MACPrefixTable
+    form = forms.MACPrefixBulkEditForm
+
+
+@register_model_view(MACPrefix, 'bulk_delete', path='delete', detail=False)
+class MACPrefixBulkDeleteView(generic.BulkDeleteView):
+    queryset = MACPrefix.objects.annotate_mac_address_count()
+    filterset = filtersets.MACPrefixFilterSet
+    table = tables.MACPrefixTable

+ 1 - 0
netbox/netbox/constants.py

@@ -28,6 +28,7 @@ ADVISORY_LOCK_KEYS = {
     'available-ips': 100200,
     'available-ips': 100200,
     'available-vlans': 100300,
     'available-vlans': 100300,
     'available-asns': 100400,
     'available-asns': 100400,
+    'available-mac-addresses': 100500,
 
 
     # Jobs
     # Jobs
     'job-schedules': 110100,
     'job-schedules': 110100,

+ 1 - 0
netbox/netbox/navigation/menu.py

@@ -121,6 +121,7 @@ DEVICES_MENU = Menu(
             label=_('Addressing'),
             label=_('Addressing'),
             items=(
             items=(
                 get_model_item('dcim', 'macaddress', _('MAC Addresses')),
                 get_model_item('dcim', 'macaddress', _('MAC Addresses')),
+                get_model_item('dcim', 'macprefix', _('MAC Prefixes')),
             ),
             ),
         ),
         ),
     ),
     ),