Jelajahi Sumber

Closes #13428: Q-in-Q VLANs (#17822)

* Initial work on #13428 (QinQ)

* Misc cleanup; add tests for Q-in-Q fields

* Address PR feedback
Jeremy Stretch 1 tahun lalu
induk
melakukan
8767fd8186
40 mengubah file dengan 492 tambahan dan 70 penghapusan
  1. 4 0
      docs/models/dcim/interface.md
  2. 8 0
      docs/models/ipam/vlan.md
  3. 4 0
      docs/models/virtualization/vminterface.md
  4. 5 4
      netbox/dcim/api/serializers_/device_components.py
  5. 2 0
      netbox/dcim/choices.py
  6. 4 2
      netbox/dcim/filtersets.py
  7. 2 0
      netbox/dcim/forms/common.py
  8. 16 2
      netbox/dcim/forms/model_forms.py
  9. 1 0
      netbox/dcim/graphql/types.py
  10. 28 0
      netbox/dcim/migrations/0196_qinq_svlan.py
  11. 32 15
      netbox/dcim/models/device_components.py
  12. 10 6
      netbox/dcim/tables/devices.py
  13. 6 0
      netbox/dcim/tests/test_api.py
  14. 25 4
      netbox/dcim/tests/test_filtersets.py
  15. 8 0
      netbox/ipam/api/serializers_/nested.py
  16. 5 2
      netbox/ipam/api/serializers_/vlans.py
  17. 11 0
      netbox/ipam/choices.py
  18. 11 0
      netbox/ipam/filtersets.py
  19. 15 1
      netbox/ipam/forms/bulk_edit.py
  20. 17 1
      netbox/ipam/forms/bulk_import.py
  21. 12 0
      netbox/ipam/forms/filtersets.py
  22. 10 2
      netbox/ipam/forms/model_forms.py
  23. 5 1
      netbox/ipam/graphql/types.py
  24. 30 0
      netbox/ipam/migrations/0075_vlan_qinq.py
  25. 39 1
      netbox/ipam/models/vlans.py
  26. 8 1
      netbox/ipam/tables/vlans.py
  27. 7 0
      netbox/ipam/tests/test_api.py
  28. 24 0
      netbox/ipam/tests/test_filtersets.py
  29. 21 0
      netbox/ipam/tests/test_models.py
  30. 31 0
      netbox/templates/ipam/vlan.html
  31. 8 0
      netbox/templates/ipam/vlan_edit.html
  32. 4 3
      netbox/virtualization/api/serializers_/virtualmachines.py
  33. 17 3
      netbox/virtualization/forms/model_forms.py
  34. 1 0
      netbox/virtualization/graphql/types.py
  35. 0 2
      netbox/virtualization/migrations/0042_vminterface_vlan_translation_policy.py
  36. 28 0
      netbox/virtualization/migrations/0043_qinq_svlan.py
  37. 0 14
      netbox/virtualization/models/virtualmachines.py
  38. 4 3
      netbox/virtualization/tables/virtualmachines.py
  39. 8 0
      netbox/virtualization/tests/test_api.py
  40. 21 3
      netbox/virtualization/tests/test_filtersets.py

+ 4 - 0
docs/models/dcim/interface.md

@@ -120,6 +120,10 @@ The "native" (untagged) VLAN for the interface. Valid only when one of the above
 
 
 The tagged VLANs which are configured to be carried by this interface. Valid only for the "tagged" 802.1Q mode above.
 The tagged VLANs which are configured to be carried by this interface. Valid only for the "tagged" 802.1Q mode above.
 
 
+### Q-in-Q SVLAN
+
+The assigned service VLAN (for Q-in-Q/802.1ad interfaces).
+
 ### Wireless Role
 ### Wireless Role
 
 
 Indicates the configured role for wireless interfaces (access point or station).
 Indicates the configured role for wireless interfaces (access point or station).

+ 8 - 0
docs/models/ipam/vlan.md

@@ -26,3 +26,11 @@ The user-defined functional [role](./role.md) assigned to the VLAN.
 ### VLAN Group or Site
 ### VLAN Group or Site
 
 
 The [VLAN group](./vlangroup.md) or [site](../dcim/site.md) to which the VLAN is assigned.
 The [VLAN group](./vlangroup.md) or [site](../dcim/site.md) to which the VLAN is assigned.
+
+### Q-in-Q Role
+
+For VLANs which comprise a Q-in-Q/IEEE 802.1ad topology, this field indicates whether the VLAN is treated as a service or customer VLAN.
+
+### Q-in-Q Service VLAN
+
+The designated parent service VLAN for a Q-in-Q customer VLAN. This may be set only for Q-in-Q custom VLANs.

+ 4 - 0
docs/models/virtualization/vminterface.md

@@ -53,6 +53,10 @@ The "native" (untagged) VLAN for the interface. Valid only when one of the above
 
 
 The tagged VLANs which are configured to be carried by this interface. Valid only for the "tagged" 802.1Q mode above.
 The tagged VLANs which are configured to be carried by this interface. Valid only for the "tagged" 802.1Q mode above.
 
 
+### Q-in-Q SVLAN
+
+The assigned service VLAN (for Q-in-Q/802.1ad interfaces).
+
 ### VRF
 ### VRF
 
 
 The [virtual routing and forwarding](../ipam/vrf.md) instance to which this interface is assigned.
 The [virtual routing and forwarding](../ipam/vrf.md) instance to which this interface is assigned.

+ 5 - 4
netbox/dcim/api/serializers_/device_components.py

@@ -196,6 +196,7 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
         required=False,
         required=False,
         many=True
         many=True
     )
     )
+    qinq_svlan = VLANSerializer(nested=True, required=False, allow_null=True)
     vlan_translation_policy = VLANTranslationPolicySerializer(nested=True, required=False, allow_null=True)
     vlan_translation_policy = VLANTranslationPolicySerializer(nested=True, required=False, allow_null=True)
     vrf = VRFSerializer(nested=True, required=False, allow_null=True)
     vrf = VRFSerializer(nested=True, required=False, allow_null=True)
     l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True)
     l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True)
@@ -223,10 +224,10 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
             'id', 'url', 'display_url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'enabled',
             'id', 'url', 'display_url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'enabled',
             'parent', 'bridge', 'lag', 'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description',
             'parent', 'bridge', 'lag', 'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description',
             'mode', 'rf_role', 'rf_channel', 'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width',
             'mode', 'rf_role', 'rf_channel', 'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width',
-            'tx_power', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'cable_end', 'wireless_link',
-            'link_peers', 'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints',
-            'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
-            'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied', 'vlan_translation_policy'
+            'tx_power', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'mark_connected',
+            'cable', 'cable_end', 'wireless_link', 'link_peers', 'link_peers_type', 'wireless_lans', 'vrf',
+            'l2vpn_termination', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable',
+            'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
         ]
         ]
         brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
         brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
 
 

+ 2 - 0
netbox/dcim/choices.py

@@ -1258,11 +1258,13 @@ class InterfaceModeChoices(ChoiceSet):
     MODE_ACCESS = 'access'
     MODE_ACCESS = 'access'
     MODE_TAGGED = 'tagged'
     MODE_TAGGED = 'tagged'
     MODE_TAGGED_ALL = 'tagged-all'
     MODE_TAGGED_ALL = 'tagged-all'
+    MODE_Q_IN_Q = 'q-in-q'
 
 
     CHOICES = (
     CHOICES = (
         (MODE_ACCESS, _('Access')),
         (MODE_ACCESS, _('Access')),
         (MODE_TAGGED, _('Tagged')),
         (MODE_TAGGED, _('Tagged')),
         (MODE_TAGGED_ALL, _('Tagged (All)')),
         (MODE_TAGGED_ALL, _('Tagged (All)')),
+        (MODE_Q_IN_Q, _('Q-in-Q (802.1ad)')),
     )
     )
 
 
 
 

+ 4 - 2
netbox/dcim/filtersets.py

@@ -1647,7 +1647,8 @@ class CommonInterfaceFilterSet(django_filters.FilterSet):
             return queryset
             return queryset
         return queryset.filter(
         return queryset.filter(
             Q(untagged_vlan_id=value) |
             Q(untagged_vlan_id=value) |
-            Q(tagged_vlans=value)
+            Q(tagged_vlans=value) |
+            Q(qinq_svlan=value)
         )
         )
 
 
     def filter_vlan(self, queryset, name, value):
     def filter_vlan(self, queryset, name, value):
@@ -1656,7 +1657,8 @@ class CommonInterfaceFilterSet(django_filters.FilterSet):
             return queryset
             return queryset
         return queryset.filter(
         return queryset.filter(
             Q(untagged_vlan_id__vid=value) |
             Q(untagged_vlan_id__vid=value) |
-            Q(tagged_vlans__vid=value)
+            Q(tagged_vlans__vid=value) |
+            Q(qinq_svlan__vid=value)
         )
         )
 
 
 
 

+ 2 - 0
netbox/dcim/forms/common.py

@@ -37,6 +37,8 @@ class InterfaceCommonForm(forms.Form):
             del self.fields['vlan_group']
             del self.fields['vlan_group']
             del self.fields['untagged_vlan']
             del self.fields['untagged_vlan']
             del self.fields['tagged_vlans']
             del self.fields['tagged_vlans']
+        if interface_mode != InterfaceModeChoices.MODE_Q_IN_Q:
+            del self.fields['qinq_svlan']
 
 
     def clean(self):
     def clean(self):
         super().clean()
         super().clean()

+ 16 - 2
netbox/dcim/forms/model_forms.py

@@ -7,6 +7,7 @@ from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.models import *
 from dcim.models import *
 from extras.models import ConfigTemplate
 from extras.models import ConfigTemplate
+from ipam.choices import VLANQinQRoleChoices
 from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VLANTranslationPolicy, VRF
 from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VLANTranslationPolicy, VRF
 from netbox.forms import NetBoxModelForm
 from netbox.forms import NetBoxModelForm
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
@@ -1372,6 +1373,16 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
             'available_on_device': '$device',
             'available_on_device': '$device',
         }
         }
     )
     )
+    qinq_svlan = DynamicModelMultipleChoiceField(
+        queryset=VLAN.objects.all(),
+        required=False,
+        label=_('Q-in-Q Service VLAN'),
+        query_params={
+            'group_id': '$vlan_group',
+            'available_on_device': '$device',
+            'qinq_role': VLANQinQRoleChoices.ROLE_SERVICE,
+        }
+    )
     vrf = DynamicModelChoiceField(
     vrf = DynamicModelChoiceField(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
         required=False,
         required=False,
@@ -1396,7 +1407,10 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
         FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')),
         FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')),
         FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')),
         FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')),
         FieldSet('poe_mode', 'poe_type', name=_('PoE')),
         FieldSet('poe_mode', 'poe_type', name=_('PoE')),
-        FieldSet('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vlan_translation_policy', name=_('802.1Q Switching')),
+        FieldSet(
+            'mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy',
+            name=_('802.1Q Switching')
+        ),
         FieldSet(
         FieldSet(
             'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans',
             'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans',
             name=_('Wireless')
             name=_('Wireless')
@@ -1409,7 +1423,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
             'device', 'module', 'vdcs', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag',
             'device', 'module', 'vdcs', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag',
             'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'poe_mode', 'poe_type', 'mode',
             'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'poe_mode', 'poe_type', 'mode',
             'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans',
             'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans',
-            'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', 'vlan_translation_policy',
+            'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf', 'tags',
         ]
         ]
         widgets = {
         widgets = {
             'speed': NumberWithOptions(
             'speed': NumberWithOptions(

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

@@ -385,6 +385,7 @@ class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, P
     wireless_link: Annotated["WirelessLinkType", strawberry.lazy('wireless.graphql.types')] | None
     wireless_link: Annotated["WirelessLinkType", strawberry.lazy('wireless.graphql.types')] | None
     untagged_vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
     untagged_vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
     vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
     vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
+    qinq_svlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
     vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None
     vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None
 
 
     vdcs: List[Annotated["VirtualDeviceContextType", strawberry.lazy('dcim.graphql.types')]]
     vdcs: List[Annotated["VirtualDeviceContextType", strawberry.lazy('dcim.graphql.types')]]

+ 28 - 0
netbox/dcim/migrations/0196_qinq_svlan.py

@@ -0,0 +1,28 @@
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0195_interface_vlan_translation_policy'),
+        ('ipam', '0075_vlan_qinq'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='interface',
+            name='qinq_svlan',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)ss_svlan', to='ipam.vlan'),
+        ),
+        migrations.AlterField(
+            model_name='interface',
+            name='tagged_vlans',
+            field=models.ManyToManyField(blank=True, related_name='%(class)ss_as_tagged', to='ipam.vlan'),
+        ),
+        migrations.AlterField(
+            model_name='interface',
+            name='untagged_vlan',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)ss_as_untagged', to='ipam.vlan'),
+        ),
+    ]

+ 32 - 15
netbox/dcim/models/device_components.py

@@ -547,17 +547,48 @@ class BaseInterface(models.Model):
         blank=True,
         blank=True,
         verbose_name=_('bridge interface')
         verbose_name=_('bridge interface')
     )
     )
+    untagged_vlan = models.ForeignKey(
+        to='ipam.VLAN',
+        on_delete=models.SET_NULL,
+        related_name='%(class)ss_as_untagged',
+        null=True,
+        blank=True,
+        verbose_name=_('untagged VLAN')
+    )
+    tagged_vlans = models.ManyToManyField(
+        to='ipam.VLAN',
+        related_name='%(class)ss_as_tagged',
+        blank=True,
+        verbose_name=_('tagged VLANs')
+    )
+    qinq_svlan = models.ForeignKey(
+        to='ipam.VLAN',
+        on_delete=models.SET_NULL,
+        related_name='%(class)ss_svlan',
+        null=True,
+        blank=True,
+        verbose_name=_('Q-in-Q SVLAN')
+    )
     vlan_translation_policy = models.ForeignKey(
     vlan_translation_policy = models.ForeignKey(
         to='ipam.VLANTranslationPolicy',
         to='ipam.VLANTranslationPolicy',
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
         null=True,
         null=True,
         blank=True,
         blank=True,
-        verbose_name=_('VLAN Translation Policy'),
+        verbose_name=_('VLAN Translation Policy')
     )
     )
 
 
     class Meta:
     class Meta:
         abstract = True
         abstract = True
 
 
+    def clean(self):
+        super().clean()
+
+        # SVLAN can be defined only for Q-in-Q interfaces
+        if self.qinq_svlan and self.mode != InterfaceModeChoices.MODE_Q_IN_Q:
+            raise ValidationError({
+                'qinq_svlan': _("Only Q-in-Q interfaces may specify a service VLAN.")
+            })
+
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
 
 
         # Remove untagged VLAN assignment for non-802.1Q interfaces
         # Remove untagged VLAN assignment for non-802.1Q interfaces
@@ -697,20 +728,6 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
         blank=True,
         blank=True,
         verbose_name=_('wireless LANs')
         verbose_name=_('wireless LANs')
     )
     )
-    untagged_vlan = models.ForeignKey(
-        to='ipam.VLAN',
-        on_delete=models.SET_NULL,
-        related_name='interfaces_as_untagged',
-        null=True,
-        blank=True,
-        verbose_name=_('untagged VLAN')
-    )
-    tagged_vlans = models.ManyToManyField(
-        to='ipam.VLAN',
-        related_name='interfaces_as_tagged',
-        blank=True,
-        verbose_name=_('tagged VLANs')
-    )
     vrf = models.ForeignKey(
     vrf = models.ForeignKey(
         to='ipam.VRF',
         to='ipam.VRF',
         on_delete=models.SET_NULL,
         on_delete=models.SET_NULL,

+ 10 - 6
netbox/dcim/tables/devices.py

@@ -585,6 +585,10 @@ class BaseInterfaceTable(NetBoxTable):
         orderable=False,
         orderable=False,
         verbose_name=_('Tagged VLANs')
         verbose_name=_('Tagged VLANs')
     )
     )
+    qinq_svlan = tables.Column(
+        verbose_name=_('Q-in-Q SVLAN'),
+        linkify=True
+    )
 
 
     def value_ip_addresses(self, value):
     def value_ip_addresses(self, value):
         return ",".join([str(obj.address) for obj in value.all()])
         return ",".join([str(obj.address) for obj in value.all()])
@@ -635,11 +639,11 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
         model = models.Interface
         model = models.Interface
         fields = (
         fields = (
             'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu',
             'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu',
-            'speed', 'speed_formatted', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel',
-            'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable',
-            'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn',
-            'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'inventory_items', 'created',
-            'last_updated',
+            'speed', 'speed_formatted', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role',
+            'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected',
+            'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf',
+            'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan',
+            'inventory_items', 'created', 'last_updated',
         )
         )
         default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
         default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
 
 
@@ -676,7 +680,7 @@ class DeviceInterfaceTable(InterfaceTable):
             'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency',
             'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency',
             'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link',
             'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link',
             'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses',
             'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses',
-            'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'actions',
+            'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'actions',
         )
         )
         default_columns = (
         default_columns = (
             'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses',
             'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses',

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

@@ -7,6 +7,7 @@ from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.models import *
 from dcim.models import *
 from extras.models import ConfigTemplate
 from extras.models import ConfigTemplate
+from ipam.choices import VLANQinQRoleChoices
 from ipam.models import ASN, RIR, VLAN, VRF
 from ipam.models import ASN, RIR, VLAN, VRF
 from netbox.api.serializers import GenericObjectSerializer
 from netbox.api.serializers import GenericObjectSerializer
 from tenancy.models import Tenant
 from tenancy.models import Tenant
@@ -1618,6 +1619,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
             VLAN(name='VLAN 1', vid=1),
             VLAN(name='VLAN 1', vid=1),
             VLAN(name='VLAN 2', vid=2),
             VLAN(name='VLAN 2', vid=2),
             VLAN(name='VLAN 3', vid=3),
             VLAN(name='VLAN 3', vid=3),
+            VLAN(name='SVLAN 1', vid=1001, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
         )
         )
         VLAN.objects.bulk_create(vlans)
         VLAN.objects.bulk_create(vlans)
 
 
@@ -1676,18 +1678,22 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
                 'vdcs': [vdcs[1].pk],
                 'vdcs': [vdcs[1].pk],
                 'name': 'Interface 7',
                 'name': 'Interface 7',
                 'type': InterfaceTypeChoices.TYPE_80211A,
                 'type': InterfaceTypeChoices.TYPE_80211A,
+                'mode': InterfaceModeChoices.MODE_Q_IN_Q,
                 'tx_power': 10,
                 'tx_power': 10,
                 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk],
                 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk],
                 'rf_channel': WirelessChannelChoices.CHANNEL_5G_32,
                 'rf_channel': WirelessChannelChoices.CHANNEL_5G_32,
+                'qinq_svlan': vlans[3].pk,
             },
             },
             {
             {
                 'device': device.pk,
                 'device': device.pk,
                 'vdcs': [vdcs[1].pk],
                 'vdcs': [vdcs[1].pk],
                 'name': 'Interface 8',
                 'name': 'Interface 8',
                 'type': InterfaceTypeChoices.TYPE_80211A,
                 'type': InterfaceTypeChoices.TYPE_80211A,
+                'mode': InterfaceModeChoices.MODE_Q_IN_Q,
                 'tx_power': 10,
                 'tx_power': 10,
                 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk],
                 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk],
                 'rf_channel': "",
                 'rf_channel': "",
+                'qinq_svlan': vlans[3].pk,
             },
             },
         ]
         ]
 
 

+ 25 - 4
netbox/dcim/tests/test_filtersets.py

@@ -4,7 +4,8 @@ from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
 from dcim.choices import *
 from dcim.choices import *
 from dcim.filtersets import *
 from dcim.filtersets import *
 from dcim.models import *
 from dcim.models import *
-from ipam.models import ASN, IPAddress, RIR, VLANTranslationPolicy, VRF
+from ipam.choices import VLANQinQRoleChoices
+from ipam.models import ASN, IPAddress, RIR, VLAN, VLANTranslationPolicy, VRF
 from netbox.choices import ColorChoices, WeightUnitChoices
 from netbox.choices import ColorChoices, WeightUnitChoices
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from users.models import User
 from users.models import User
@@ -3520,7 +3521,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
 class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
 class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = Interface.objects.all()
     queryset = Interface.objects.all()
     filterset = InterfaceFilterSet
     filterset = InterfaceFilterSet
-    ignore_fields = ('tagged_vlans', 'untagged_vlan', 'vdcs')
+    ignore_fields = ('tagged_vlans', 'untagged_vlan', 'qinq_svlan', 'vdcs')
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -3669,6 +3670,13 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
         )
         )
         VirtualDeviceContext.objects.bulk_create(vdcs)
         VirtualDeviceContext.objects.bulk_create(vdcs)
 
 
+        vlans = (
+            VLAN(name='SVLAN 1', vid=1001, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
+            VLAN(name='SVLAN 2', vid=1002, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
+            VLAN(name='SVLAN 3', vid=1003, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
+        )
+        VLAN.objects.bulk_create(vlans)
+
         vlan_translation_policies = (
         vlan_translation_policies = (
             VLANTranslationPolicy(name='Policy 1'),
             VLANTranslationPolicy(name='Policy 1'),
             VLANTranslationPolicy(name='Policy 2'),
             VLANTranslationPolicy(name='Policy 2'),
@@ -3753,6 +3761,8 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 duplex='full',
                 duplex='full',
                 poe_mode=InterfacePoEModeChoices.MODE_PD,
                 poe_mode=InterfacePoEModeChoices.MODE_PD,
                 poe_type=InterfacePoETypeChoices.TYPE_2_8023AT,
                 poe_type=InterfacePoETypeChoices.TYPE_2_8023AT,
+                mode=InterfaceModeChoices.MODE_Q_IN_Q,
+                qinq_svlan=vlans[0],
                 vlan_translation_policy=vlan_translation_policies[1],
                 vlan_translation_policy=vlan_translation_policies[1],
             ),
             ),
             Interface(
             Interface(
@@ -3762,7 +3772,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 type=InterfaceTypeChoices.TYPE_OTHER,
                 type=InterfaceTypeChoices.TYPE_OTHER,
                 enabled=True,
                 enabled=True,
                 mgmt_only=True,
                 mgmt_only=True,
-                tx_power=40
+                tx_power=40,
+                mode=InterfaceModeChoices.MODE_Q_IN_Q,
+                qinq_svlan=vlans[1]
             ),
             ),
             Interface(
             Interface(
                 device=devices[4],
                 device=devices[4],
@@ -3771,7 +3783,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 type=InterfaceTypeChoices.TYPE_OTHER,
                 type=InterfaceTypeChoices.TYPE_OTHER,
                 enabled=False,
                 enabled=False,
                 mgmt_only=False,
                 mgmt_only=False,
-                tx_power=40
+                tx_power=40,
+                mode=InterfaceModeChoices.MODE_Q_IN_Q,
+                qinq_svlan=vlans[2]
             ),
             ),
             Interface(
             Interface(
                 device=devices[4],
                 device=devices[4],
@@ -4027,6 +4041,13 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
         params = {'vdc_identifier': vdc.values_list('identifier', flat=True)}
         params = {'vdc_identifier': vdc.values_list('identifier', flat=True)}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
 
 
+    def test_vlan(self):
+        vlan = VLAN.objects.filter(qinq_role=VLANQinQRoleChoices.ROLE_SERVICE).first()
+        params = {'vlan_id': vlan.pk}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'vlan': vlan.vid}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
     def test_vlan_translation_policy(self):
     def test_vlan_translation_policy(self):
         vlan_translation_policies = VLANTranslationPolicy.objects.all()[:2]
         vlan_translation_policies = VLANTranslationPolicy.objects.all()[:2]
         params = {'vlan_translation_policy_id': [vlan_translation_policies[0].pk, vlan_translation_policies[1].pk]}
         params = {'vlan_translation_policy_id': [vlan_translation_policies[0].pk, vlan_translation_policies[1].pk]}

+ 8 - 0
netbox/ipam/api/serializers_/nested.py

@@ -6,6 +6,7 @@ from ..field_serializers import IPAddressField
 
 
 __all__ = (
 __all__ = (
     'NestedIPAddressSerializer',
     'NestedIPAddressSerializer',
+    'NestedVLANSerializer',
 )
 )
 
 
 
 
@@ -16,3 +17,10 @@ class NestedIPAddressSerializer(WritableNestedSerializer):
     class Meta:
     class Meta:
         model = models.IPAddress
         model = models.IPAddress
         fields = ['id', 'url', 'display_url', 'display', 'family', 'address']
         fields = ['id', 'url', 'display_url', 'display', 'family', 'address']
+
+
+class NestedVLANSerializer(WritableNestedSerializer):
+
+    class Meta:
+        model = models.VLAN
+        fields = ['id', 'url', 'display', 'vid', 'name', 'description']

+ 5 - 2
netbox/ipam/api/serializers_/vlans.py

@@ -11,6 +11,7 @@ from netbox.api.serializers import NetBoxModelSerializer
 from tenancy.api.serializers_.tenants import TenantSerializer
 from tenancy.api.serializers_.tenants import TenantSerializer
 from utilities.api import get_serializer_for_model
 from utilities.api import get_serializer_for_model
 from vpn.api.serializers_.l2vpn import L2VPNTerminationSerializer
 from vpn.api.serializers_.l2vpn import L2VPNTerminationSerializer
+from .nested import NestedVLANSerializer
 from .roles import RoleSerializer
 from .roles import RoleSerializer
 
 
 __all__ = (
 __all__ = (
@@ -64,6 +65,8 @@ class VLANSerializer(NetBoxModelSerializer):
     tenant = TenantSerializer(nested=True, required=False, allow_null=True)
     tenant = TenantSerializer(nested=True, required=False, allow_null=True)
     status = ChoiceField(choices=VLANStatusChoices, required=False)
     status = ChoiceField(choices=VLANStatusChoices, required=False)
     role = RoleSerializer(nested=True, required=False, allow_null=True)
     role = RoleSerializer(nested=True, required=False, allow_null=True)
+    qinq_role = ChoiceField(choices=VLANQinQRoleChoices, required=False)
+    qinq_svlan = NestedVLANSerializer(required=False, allow_null=True, default=None)
     l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True)
     l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True)
 
 
     # Related object counts
     # Related object counts
@@ -73,8 +76,8 @@ class VLANSerializer(NetBoxModelSerializer):
         model = VLAN
         model = VLAN
         fields = [
         fields = [
             'id', 'url', 'display_url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role',
             'id', 'url', 'display_url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role',
-            'description', 'comments', 'l2vpn_termination', 'tags', 'custom_fields', 'created', 'last_updated',
-            'prefix_count',
+            'description', 'qinq_role', 'qinq_svlan', 'comments', 'l2vpn_termination', 'tags', 'custom_fields',
+            'created', 'last_updated', 'prefix_count',
         ]
         ]
         brief_fields = ('id', 'url', 'display', 'vid', 'name', 'description')
         brief_fields = ('id', 'url', 'display', 'vid', 'name', 'description')
 
 

+ 11 - 0
netbox/ipam/choices.py

@@ -157,6 +157,17 @@ class VLANStatusChoices(ChoiceSet):
     ]
     ]
 
 
 
 
+class VLANQinQRoleChoices(ChoiceSet):
+
+    ROLE_SERVICE = 's-vlan'
+    ROLE_CUSTOMER = 'c-vlan'
+
+    CHOICES = [
+        (ROLE_SERVICE, _('Service'), 'blue'),
+        (ROLE_CUSTOMER, _('Customer'), 'orange'),
+    ]
+
+
 #
 #
 # Services
 # Services
 #
 #

+ 11 - 0
netbox/ipam/filtersets.py

@@ -1041,6 +1041,17 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
         queryset=VirtualMachine.objects.all(),
         queryset=VirtualMachine.objects.all(),
         method='get_for_virtualmachine'
         method='get_for_virtualmachine'
     )
     )
+    qinq_role = django_filters.MultipleChoiceFilter(
+        choices=VLANQinQRoleChoices
+    )
+    qinq_svlan_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=VLAN.objects.all(),
+        label=_('Q-in-Q SVLAN (ID)'),
+    )
+    qinq_svlan_vid = MultiValueNumberFilter(
+        field_name='qinq_svlan__vid',
+        label=_('Q-in-Q SVLAN number (1-4094)'),
+    )
     l2vpn_id = django_filters.ModelMultipleChoiceFilter(
     l2vpn_id = django_filters.ModelMultipleChoiceFilter(
         field_name='l2vpn_terminations__l2vpn',
         field_name='l2vpn_terminations__l2vpn',
         queryset=L2VPN.objects.all(),
         queryset=L2VPN.objects.all(),

+ 15 - 1
netbox/ipam/forms/bulk_edit.py

@@ -527,15 +527,29 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm):
         max_length=200,
         max_length=200,
         required=False
         required=False
     )
     )
+    qinq_role = forms.ChoiceField(
+        label=_('Q-in-Q role'),
+        choices=add_blank_choice(VLANQinQRoleChoices),
+        required=False
+    )
+    qinq_svlan = DynamicModelChoiceField(
+        label=_('Q-in-Q SVLAN'),
+        queryset=VLAN.objects.all(),
+        required=False,
+        query_params={
+            'qinq_role': VLANQinQRoleChoices.ROLE_SERVICE,
+        }
+    )
     comments = CommentField()
     comments = CommentField()
 
 
     model = VLAN
     model = VLAN
     fieldsets = (
     fieldsets = (
         FieldSet('status', 'role', 'tenant', 'description'),
         FieldSet('status', 'role', 'tenant', 'description'),
+        FieldSet('qinq_role', 'qinq_svlan', name=_('Q-in-Q')),
         FieldSet('region', 'site_group', 'site', 'group', name=_('Site & Group')),
         FieldSet('region', 'site_group', 'site', 'group', name=_('Site & Group')),
     )
     )
     nullable_fields = (
     nullable_fields = (
-        'site', 'group', 'tenant', 'role', 'description', 'comments',
+        'site', 'group', 'tenant', 'role', 'description', 'qinq_role', 'qinq_svlan', 'comments',
     )
     )
 
 
 
 

+ 17 - 1
netbox/ipam/forms/bulk_import.py

@@ -461,10 +461,26 @@ class VLANImportForm(NetBoxModelImportForm):
         to_field_name='name',
         to_field_name='name',
         help_text=_('Functional role')
         help_text=_('Functional role')
     )
     )
+    qinq_role = CSVChoiceField(
+        label=_('Q-in-Q role'),
+        choices=VLANStatusChoices,
+        required=False,
+        help_text=_('Operational status')
+    )
+    qinq_svlan = CSVModelChoiceField(
+        label=_('Q-in-Q SVLAN'),
+        queryset=VLAN.objects.all(),
+        required=False,
+        to_field_name='vid',
+        help_text=_("Service VLAN (for Q-in-Q/802.1ad customer VLANs)")
+    )
 
 
     class Meta:
     class Meta:
         model = VLAN
         model = VLAN
-        fields = ('site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'comments', 'tags')
+        fields = (
+            'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'qinq_role', 'qinq_svlan',
+            'comments', 'tags',
+        )
 
 
 
 
 class VLANTranslationPolicyImportForm(NetBoxModelImportForm):
 class VLANTranslationPolicyImportForm(NetBoxModelImportForm):

+ 12 - 0
netbox/ipam/forms/filtersets.py

@@ -506,6 +506,7 @@ class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
         FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
         FieldSet('group_id', 'status', 'role_id', 'vid', 'l2vpn_id', name=_('Attributes')),
         FieldSet('group_id', 'status', 'role_id', 'vid', 'l2vpn_id', name=_('Attributes')),
+        FieldSet('qinq_role', 'qinq_svlan_id', name=_('Q-in-Q/802.1ad')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
     )
     )
     selector_fields = ('filter_id', 'q', 'site_id')
     selector_fields = ('filter_id', 'q', 'site_id')
@@ -552,6 +553,17 @@ class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
         required=False,
         required=False,
         label=_('VLAN ID')
         label=_('VLAN ID')
     )
     )
+    qinq_role = forms.MultipleChoiceField(
+        label=_('Q-in-Q role'),
+        choices=VLANQinQRoleChoices,
+        required=False
+    )
+    qinq_svlan_id = DynamicModelMultipleChoiceField(
+        queryset=VLAN.objects.all(),
+        required=False,
+        null_option='None',
+        label=_('Q-in-Q SVLAN')
+    )
     l2vpn_id = DynamicModelMultipleChoiceField(
     l2vpn_id = DynamicModelMultipleChoiceField(
         queryset=L2VPN.objects.all(),
         queryset=L2VPN.objects.all(),
         required=False,
         required=False,

+ 10 - 2
netbox/ipam/forms/model_forms.py

@@ -683,13 +683,21 @@ class VLANForm(TenancyForm, NetBoxModelForm):
         queryset=Role.objects.all(),
         queryset=Role.objects.all(),
         required=False
         required=False
     )
     )
+    qinq_svlan = DynamicModelChoiceField(
+        label=_('Q-in-Q SVLAN'),
+        queryset=VLAN.objects.all(),
+        required=False,
+        query_params={
+            'qinq_role': VLANQinQRoleChoices.ROLE_SERVICE,
+        }
+    )
     comments = CommentField()
     comments = CommentField()
 
 
     class Meta:
     class Meta:
         model = VLAN
         model = VLAN
         fields = [
         fields = [
-            'site', 'group', 'vid', 'name', 'status', 'role', 'tenant_group', 'tenant', 'description', 'comments',
-            'tags',
+            'site', 'group', 'vid', 'name', 'status', 'role', 'tenant_group', 'tenant', 'qinq_role', 'qinq_svlan',
+            'description', 'comments', 'tags',
         ]
         ]
 
 
 
 

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

@@ -236,7 +236,7 @@ class ServiceTemplateType(NetBoxObjectType):
 
 
 @strawberry_django.type(
 @strawberry_django.type(
     models.VLAN,
     models.VLAN,
-    fields='__all__',
+    exclude=('qinq_svlan',),
     filters=VLANFilter
     filters=VLANFilter
 )
 )
 class VLANType(NetBoxObjectType):
 class VLANType(NetBoxObjectType):
@@ -252,6 +252,10 @@ class VLANType(NetBoxObjectType):
     interfaces_as_tagged: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]]
     interfaces_as_tagged: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]]
     vminterfaces_as_tagged: List[Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')]]
     vminterfaces_as_tagged: List[Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')]]
 
 
+    @strawberry_django.field
+    def qinq_svlan(self) -> Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None:
+        return self.qinq_svlan
+
 
 
 @strawberry_django.type(
 @strawberry_django.type(
     models.VLANGroup,
     models.VLANGroup,

+ 30 - 0
netbox/ipam/migrations/0075_vlan_qinq.py

@@ -0,0 +1,30 @@
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ipam', '0074_vlantranslationpolicy_vlantranslationrule'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='vlan',
+            name='qinq_role',
+            field=models.CharField(blank=True, max_length=50, null=True),
+        ),
+        migrations.AddField(
+            model_name='vlan',
+            name='qinq_svlan',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='qinq_cvlans', to='ipam.vlan'),
+        ),
+        migrations.AddConstraint(
+            model_name='vlan',
+            constraint=models.UniqueConstraint(fields=('qinq_svlan', 'vid'), name='ipam_vlan_unique_qinq_svlan_vid'),
+        ),
+        migrations.AddConstraint(
+            model_name='vlan',
+            constraint=models.UniqueConstraint(fields=('qinq_svlan', 'name'), name='ipam_vlan_unique_qinq_svlan_name'),
+        ),
+    ]

+ 39 - 1
netbox/ipam/models/vlans.py

@@ -204,6 +204,21 @@ class VLAN(PrimaryModel):
         null=True,
         null=True,
         help_text=_("The primary function of this VLAN")
         help_text=_("The primary function of this VLAN")
     )
     )
+    qinq_svlan = models.ForeignKey(
+        to='self',
+        on_delete=models.PROTECT,
+        related_name='qinq_cvlans',
+        blank=True,
+        null=True
+    )
+    qinq_role = models.CharField(
+        verbose_name=_('Q-in-Q role'),
+        max_length=50,
+        choices=VLANQinQRoleChoices,
+        blank=True,
+        null=True,
+        help_text=_("Customer/service VLAN designation (for Q-in-Q/IEEE 802.1ad)")
+    )
     l2vpn_terminations = GenericRelation(
     l2vpn_terminations = GenericRelation(
         to='vpn.L2VPNTermination',
         to='vpn.L2VPNTermination',
         content_type_field='assigned_object_type',
         content_type_field='assigned_object_type',
@@ -214,7 +229,7 @@ class VLAN(PrimaryModel):
     objects = VLANQuerySet.as_manager()
     objects = VLANQuerySet.as_manager()
 
 
     clone_fields = [
     clone_fields = [
-        'site', 'group', 'tenant', 'status', 'role', 'description',
+        'site', 'group', 'tenant', 'status', 'role', 'description', 'qinq_role', 'qinq_svlan',
     ]
     ]
 
 
     class Meta:
     class Meta:
@@ -228,6 +243,14 @@ class VLAN(PrimaryModel):
                 fields=('group', 'name'),
                 fields=('group', 'name'),
                 name='%(app_label)s_%(class)s_unique_group_name'
                 name='%(app_label)s_%(class)s_unique_group_name'
             ),
             ),
+            models.UniqueConstraint(
+                fields=('qinq_svlan', 'vid'),
+                name='%(app_label)s_%(class)s_unique_qinq_svlan_vid'
+            ),
+            models.UniqueConstraint(
+                fields=('qinq_svlan', 'name'),
+                name='%(app_label)s_%(class)s_unique_qinq_svlan_name'
+            ),
         )
         )
         verbose_name = _('VLAN')
         verbose_name = _('VLAN')
         verbose_name_plural = _('VLANs')
         verbose_name_plural = _('VLANs')
@@ -255,9 +278,24 @@ class VLAN(PrimaryModel):
                     ).format(ranges=ranges_to_string(self.group.vid_ranges), group=self.group)
                     ).format(ranges=ranges_to_string(self.group.vid_ranges), group=self.group)
                 })
                 })
 
 
+        # Only Q-in-Q customer VLANs may be assigned to a service VLAN
+        if self.qinq_svlan and self.qinq_role != VLANQinQRoleChoices.ROLE_CUSTOMER:
+            raise ValidationError({
+                'qinq_svlan': _("Only Q-in-Q customer VLANs maybe assigned to a service VLAN.")
+            })
+
+        # A Q-in-Q customer VLAN must be assigned to a service VLAN
+        if self.qinq_role == VLANQinQRoleChoices.ROLE_CUSTOMER and not self.qinq_svlan:
+            raise ValidationError({
+                'qinq_role': _("A Q-in-Q customer VLAN must be assigned to a service VLAN.")
+            })
+
     def get_status_color(self):
     def get_status_color(self):
         return VLANStatusChoices.colors.get(self.status)
         return VLANStatusChoices.colors.get(self.status)
 
 
+    def get_qinq_role_color(self):
+        return VLANQinQRoleChoices.colors.get(self.qinq_role)
+
     def get_interfaces(self):
     def get_interfaces(self):
         # Return all device interfaces assigned to this VLAN
         # Return all device interfaces assigned to this VLAN
         return Interface.objects.filter(
         return Interface.objects.filter(

+ 8 - 1
netbox/ipam/tables/vlans.py

@@ -132,6 +132,13 @@ class VLANTable(TenancyColumnsMixin, NetBoxTable):
         verbose_name=_('Role'),
         verbose_name=_('Role'),
         linkify=True
         linkify=True
     )
     )
+    qinq_role = columns.ChoiceFieldColumn(
+        verbose_name=_('Q-in-Q role')
+    )
+    qinq_svlan = tables.Column(
+        verbose_name=_('Q-in-Q SVLAN'),
+        linkify=True
+    )
     l2vpn = tables.Column(
     l2vpn = tables.Column(
         accessor=tables.A('l2vpn_termination__l2vpn'),
         accessor=tables.A('l2vpn_termination__l2vpn'),
         linkify=True,
         linkify=True,
@@ -154,7 +161,7 @@ class VLANTable(TenancyColumnsMixin, NetBoxTable):
         model = VLAN
         model = VLAN
         fields = (
         fields = (
             'pk', 'id', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'tenant_group', 'status', 'role',
             'pk', 'id', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'tenant_group', 'status', 'role',
-            'description', 'comments', 'tags', 'l2vpn', 'created', 'last_updated',
+            'qinq_role', 'qinq_svlan', 'description', 'comments', 'tags', 'l2vpn', 'created', 'last_updated',
         )
         )
         default_columns = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description')
         default_columns = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description')
         row_attrs = {
         row_attrs = {

+ 7 - 0
netbox/ipam/tests/test_api.py

@@ -980,6 +980,7 @@ class VLANTest(APIViewTestCases.APIViewTestCase):
             VLAN(name='VLAN 1', vid=1, group=vlan_groups[0]),
             VLAN(name='VLAN 1', vid=1, group=vlan_groups[0]),
             VLAN(name='VLAN 2', vid=2, group=vlan_groups[0]),
             VLAN(name='VLAN 2', vid=2, group=vlan_groups[0]),
             VLAN(name='VLAN 3', vid=3, group=vlan_groups[0]),
             VLAN(name='VLAN 3', vid=3, group=vlan_groups[0]),
+            VLAN(name='SVLAN 1', vid=1001, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
         )
         )
         VLAN.objects.bulk_create(vlans)
         VLAN.objects.bulk_create(vlans)
 
 
@@ -999,6 +1000,12 @@ class VLANTest(APIViewTestCases.APIViewTestCase):
                 'name': 'VLAN 6',
                 'name': 'VLAN 6',
                 'group': vlan_groups[1].pk,
                 'group': vlan_groups[1].pk,
             },
             },
+            {
+                'vid': 2001,
+                'name': 'CVLAN 1',
+                'qinq_role': VLANQinQRoleChoices.ROLE_CUSTOMER,
+                'qinq_svlan': vlans[3].pk,
+            },
         ]
         ]
 
 
     def test_delete_vlan_with_prefix(self):
     def test_delete_vlan_with_prefix(self):

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

@@ -1630,6 +1630,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
             Site(name='Site 4', slug='site-4', region=regions[0], group=site_groups[0]),
             Site(name='Site 4', slug='site-4', region=regions[0], group=site_groups[0]),
             Site(name='Site 5', slug='site-5', region=regions[1], group=site_groups[1]),
             Site(name='Site 5', slug='site-5', region=regions[1], group=site_groups[1]),
             Site(name='Site 6', slug='site-6', region=regions[2], group=site_groups[2]),
             Site(name='Site 6', slug='site-6', region=regions[2], group=site_groups[2]),
+            Site(name='Site 7', slug='site-7'),
         )
         )
         Site.objects.bulk_create(sites)
         Site.objects.bulk_create(sites)
 
 
@@ -1784,9 +1785,21 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
 
 
             # Create one globally available VLAN
             # Create one globally available VLAN
             VLAN(vid=1000, name='Global VLAN'),
             VLAN(vid=1000, name='Global VLAN'),
+
+            # Create some Q-in-Q service VLANs
+            VLAN(vid=2001, name='SVLAN 1', site=sites[6], qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
+            VLAN(vid=2002, name='SVLAN 2', site=sites[6], qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
+            VLAN(vid=2003, name='SVLAN 3', site=sites[6], qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
         )
         )
         VLAN.objects.bulk_create(vlans)
         VLAN.objects.bulk_create(vlans)
 
 
+        # Create Q-in-Q customer VLANs
+        VLAN.objects.bulk_create([
+            VLAN(vid=3001, name='CVLAN 1', site=sites[6], qinq_svlan=vlans[29], qinq_role=VLANQinQRoleChoices.ROLE_CUSTOMER),
+            VLAN(vid=3002, name='CVLAN 2', site=sites[6], qinq_svlan=vlans[30], qinq_role=VLANQinQRoleChoices.ROLE_CUSTOMER),
+            VLAN(vid=3003, name='CVLAN 3', site=sites[6], qinq_svlan=vlans[31], qinq_role=VLANQinQRoleChoices.ROLE_CUSTOMER),
+        ])
+
         # Assign VLANs to device interfaces
         # Assign VLANs to device interfaces
         interfaces[0].untagged_vlan = vlans[0]
         interfaces[0].untagged_vlan = vlans[0]
         interfaces[0].tagged_vlans.add(vlans[1])
         interfaces[0].tagged_vlans.add(vlans[1])
@@ -1897,6 +1910,17 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'vminterface_id': vminterface_id}
         params = {'vminterface_id': vminterface_id}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
+    def test_qinq_role(self):
+        params = {'qinq_role': [VLANQinQRoleChoices.ROLE_SERVICE, VLANQinQRoleChoices.ROLE_CUSTOMER]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
+
+    def test_qinq_svlan(self):
+        vlans = VLAN.objects.filter(qinq_role=VLANQinQRoleChoices.ROLE_SERVICE)[:2]
+        params = {'qinq_svlan_id': [vlans[0].pk, vlans[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'qinq_svlan_vid': [vlans[0].vid, vlans[1].vid]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
 
 
 class VLANTranslationPolicyTestCase(TestCase, ChangeLoggedFilterSetTests):
 class VLANTranslationPolicyTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = VLANTranslationPolicy.objects.all()
     queryset = VLANTranslationPolicy.objects.all()

+ 21 - 0
netbox/ipam/tests/test_models.py

@@ -586,3 +586,24 @@ class TestVLANGroup(TestCase):
         vlangroup.vid_ranges = string_to_ranges('2-2')
         vlangroup.vid_ranges = string_to_ranges('2-2')
         vlangroup.full_clean()
         vlangroup.full_clean()
         vlangroup.save()
         vlangroup.save()
+
+
+class TestVLAN(TestCase):
+
+    @classmethod
+    def setUpTestData(cls):
+        VLAN.objects.bulk_create((
+            VLAN(name='VLAN 1', vid=1, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
+        ))
+
+    def test_qinq_role(self):
+        svlan = VLAN.objects.filter(qinq_role=VLANQinQRoleChoices.ROLE_SERVICE).first()
+
+        vlan = VLAN(
+            name='VLAN X',
+            vid=999,
+            qinq_role=VLANQinQRoleChoices.ROLE_SERVICE,
+            qinq_svlan=svlan
+        )
+        with self.assertRaises(ValidationError):
+            vlan.full_clean()

+ 31 - 0
netbox/templates/ipam/vlan.html

@@ -62,6 +62,22 @@
                         <th scope="row">{% trans "Description" %}</th>
                         <th scope="row">{% trans "Description" %}</th>
                         <td>{{ object.description|placeholder }}</td>
                         <td>{{ object.description|placeholder }}</td>
                     </tr>
                     </tr>
+                    <tr>
+                      <th scope="row">{% trans "Q-in-Q Role" %}</th>
+                      <td>
+                        {% if object.qinq_role %}
+                          {% badge object.get_qinq_role_display bg_color=object.get_qinq_role_color %}
+                        {% else %}
+                          {{ ''|placeholder }}
+                        {% endif %}
+                      </td>
+                    </tr>
+                    {% if object.qinq_role == 'c-vlan' %}
+                      <tr>
+                        <th scope="row">{% trans "Q-in-Q SVLAN" %}</th>
+                        <td>{{ object.qinq_svlan|linkify|placeholder }}</td>
+                      </tr>
+                    {% endif %}
                     <tr>
                     <tr>
                       <th scope="row">{% trans "L2VPN" %}</th>
                       <th scope="row">{% trans "L2VPN" %}</th>
                       <td>{{ object.l2vpn_termination.l2vpn|linkify|placeholder }}</td>
                       <td>{{ object.l2vpn_termination.l2vpn|linkify|placeholder }}</td>
@@ -92,6 +108,21 @@
           </h2>
           </h2>
           {% htmx_table 'ipam:prefix_list' vlan_id=object.pk %}
           {% htmx_table 'ipam:prefix_list' vlan_id=object.pk %}
         </div>
         </div>
+        {% if object.qinq_role == 's-vlan' %}
+          <div class="card">
+            <h2 class="card-header">
+              {% trans "Customer VLANs" %}
+              {% if perms.ipam.add_vlan %}
+                <div class="card-actions">
+                  <a href="{% url 'ipam:vlan_add' %}?qinq_role=c-vlan&qinq_svlan={{ object.pk }}" class="btn btn-ghost-primary btn-sm">
+                    <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a VLAN" %}
+                  </a>
+                </div>
+              {% endif %}
+            </h2>
+            {% htmx_table 'ipam:vlan_list' qinq_svlan_id=object.pk %}
+          </div>
+        {% endif %}
         {% plugin_full_width_page object %}
         {% plugin_full_width_page object %}
       </div>
       </div>
     </div>
     </div>

+ 8 - 0
netbox/templates/ipam/vlan_edit.html

@@ -17,6 +17,14 @@
     {% render_field form.tags %}
     {% render_field form.tags %}
   </div>
   </div>
 
 
+  <div class="field-group my-5">
+    <div class="row">
+      <h2 class="col-9 offset-3">{% trans "Q-in-Q (802.1ad)" %}</h2>
+    </div>
+    {% render_field form.qinq_role %}
+    {% render_field form.qinq_svlan %}
+  </div>
+
   <div class="field-group my-5">
   <div class="field-group my-5">
     <div class="row">
     <div class="row">
       <h2 class="col-9 offset-3">{% trans "Tenancy" %}</h2>
       <h2 class="col-9 offset-3">{% trans "Tenancy" %}</h2>

+ 4 - 3
netbox/virtualization/api/serializers_/virtualmachines.py

@@ -89,6 +89,7 @@ class VMInterfaceSerializer(NetBoxModelSerializer):
         required=False,
         required=False,
         many=True
         many=True
     )
     )
+    qinq_svlan = VLANSerializer(nested=True, required=False, allow_null=True)
     vlan_translation_policy = VLANTranslationPolicySerializer(nested=True, required=False, allow_null=True)
     vlan_translation_policy = VLANTranslationPolicySerializer(nested=True, required=False, allow_null=True)
     vrf = VRFSerializer(nested=True, required=False, allow_null=True)
     vrf = VRFSerializer(nested=True, required=False, allow_null=True)
     l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True)
     l2vpn_termination = L2VPNTerminationSerializer(nested=True, read_only=True, allow_null=True)
@@ -104,9 +105,9 @@ class VMInterfaceSerializer(NetBoxModelSerializer):
         model = VMInterface
         model = VMInterface
         fields = [
         fields = [
             'id', 'url', 'display_url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'bridge', 'mtu',
             'id', 'url', 'display_url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'bridge', 'mtu',
-            'mac_address', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'vrf', 'l2vpn_termination',
-            'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups',
-            'vlan_translation_policy',
+            'mac_address', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan',
+            'vlan_translation_policy', 'vrf', 'l2vpn_termination', 'tags', 'custom_fields', 'created', 'last_updated',
+            'count_ipaddresses', 'count_fhrp_groups',
         ]
         ]
         brief_fields = ('id', 'url', 'display', 'virtual_machine', 'name', 'description')
         brief_fields = ('id', 'url', 'display', 'virtual_machine', 'name', 'description')
 
 

+ 17 - 3
netbox/virtualization/forms/model_forms.py

@@ -6,6 +6,7 @@ from django.utils.translation import gettext_lazy as _
 from dcim.forms.common import InterfaceCommonForm
 from dcim.forms.common import InterfaceCommonForm
 from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup
 from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup
 from extras.models import ConfigTemplate
 from extras.models import ConfigTemplate
+from ipam.choices import VLANQinQRoleChoices
 from ipam.models import IPAddress, VLAN, VLANGroup, VLANTranslationPolicy, VRF
 from ipam.models import IPAddress, VLAN, VLANGroup, VLANTranslationPolicy, VRF
 from netbox.forms import NetBoxModelForm
 from netbox.forms import NetBoxModelForm
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
@@ -338,6 +339,16 @@ class VMInterfaceForm(InterfaceCommonForm, VMComponentForm):
             'available_on_virtualmachine': '$virtual_machine',
             'available_on_virtualmachine': '$virtual_machine',
         }
         }
     )
     )
+    qinq_svlan = DynamicModelChoiceField(
+        queryset=VLAN.objects.all(),
+        required=False,
+        label=_('Q-in-Q Service VLAN'),
+        query_params={
+            'group_id': '$vlan_group',
+            'available_on_virtualmachine': '$virtual_machine',
+            'qinq_role': VLANQinQRoleChoices.ROLE_SERVICE,
+        }
+    )
     vrf = DynamicModelChoiceField(
     vrf = DynamicModelChoiceField(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
         required=False,
         required=False,
@@ -354,17 +365,20 @@ class VMInterfaceForm(InterfaceCommonForm, VMComponentForm):
         FieldSet('vrf', 'mac_address', name=_('Addressing')),
         FieldSet('vrf', 'mac_address', name=_('Addressing')),
         FieldSet('mtu', 'enabled', name=_('Operation')),
         FieldSet('mtu', 'enabled', name=_('Operation')),
         FieldSet('parent', 'bridge', name=_('Related Interfaces')),
         FieldSet('parent', 'bridge', name=_('Related Interfaces')),
-        FieldSet('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vlan_translation_policy', name=_('802.1Q Switching')),
+        FieldSet(
+            'mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy',
+            name=_('802.1Q Switching')
+        ),
     )
     )
 
 
     class Meta:
     class Meta:
         model = VMInterface
         model = VMInterface
         fields = [
         fields = [
             'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode',
             'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode',
-            'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', 'vlan_translation_policy',
+            'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf', 'tags',
         ]
         ]
         labels = {
         labels = {
-            'mode': '802.1Q Mode',
+            'mode': _('802.1Q Mode'),
         }
         }
         widgets = {
         widgets = {
             'mode': HTMXSelect(),
             'mode': HTMXSelect(),

+ 1 - 0
netbox/virtualization/graphql/types.py

@@ -100,6 +100,7 @@ class VMInterfaceType(IPAddressesMixin, ComponentType):
     bridge: Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')] | None
     bridge: Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')] | None
     untagged_vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
     untagged_vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
     vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
     vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
+    qinq_svlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
     vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None
     vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None
 
 
     tagged_vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]]
     tagged_vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]]

+ 0 - 2
netbox/virtualization/migrations/0042_vminterface_vlan_translation_policy.py

@@ -1,5 +1,3 @@
-# Generated by Django 5.0.9 on 2024-10-11 19:45
-
 import django.db.models.deletion
 import django.db.models.deletion
 from django.db import migrations, models
 from django.db import migrations, models
 
 

+ 28 - 0
netbox/virtualization/migrations/0043_qinq_svlan.py

@@ -0,0 +1,28 @@
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ipam', '0075_vlan_qinq'),
+        ('virtualization', '0042_vminterface_vlan_translation_policy'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='vminterface',
+            name='qinq_svlan',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)ss_svlan', to='ipam.vlan'),
+        ),
+        migrations.AlterField(
+            model_name='vminterface',
+            name='tagged_vlans',
+            field=models.ManyToManyField(blank=True, related_name='%(class)ss_as_tagged', to='ipam.vlan'),
+        ),
+        migrations.AlterField(
+            model_name='vminterface',
+            name='untagged_vlan',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)ss_as_untagged', to='ipam.vlan'),
+        ),
+    ]

+ 0 - 14
netbox/virtualization/models/virtualmachines.py

@@ -322,20 +322,6 @@ class VMInterface(ComponentModel, BaseInterface, TrackingModelMixin):
         max_length=100,
         max_length=100,
         blank=True
         blank=True
     )
     )
-    untagged_vlan = models.ForeignKey(
-        to='ipam.VLAN',
-        on_delete=models.SET_NULL,
-        related_name='vminterfaces_as_untagged',
-        null=True,
-        blank=True,
-        verbose_name=_('untagged VLAN')
-    )
-    tagged_vlans = models.ManyToManyField(
-        to='ipam.VLAN',
-        related_name='vminterfaces_as_tagged',
-        blank=True,
-        verbose_name=_('tagged VLANs')
-    )
     ip_addresses = GenericRelation(
     ip_addresses = GenericRelation(
         to='ipam.IPAddress',
         to='ipam.IPAddress',
         content_type_field='assigned_object_type',
         content_type_field='assigned_object_type',

+ 4 - 3
netbox/virtualization/tables/virtualmachines.py

@@ -151,8 +151,8 @@ class VMInterfaceTable(BaseInterfaceTable):
         model = VMInterface
         model = VMInterface
         fields = (
         fields = (
             'pk', 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags',
             'pk', 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags',
-            'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created',
-            'last_updated',
+            'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan',
+            'created', 'last_updated',
         )
         )
         default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description')
         default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description')
 
 
@@ -175,7 +175,8 @@ class VirtualMachineVMInterfaceTable(VMInterfaceTable):
         model = VMInterface
         model = VMInterface
         fields = (
         fields = (
             'pk', 'id', 'name', 'enabled', 'parent', 'bridge', 'mac_address', 'mtu', 'mode', 'description', 'tags',
             'pk', 'id', 'name', 'enabled', 'parent', 'bridge', 'mac_address', 'mtu', 'mode', 'description', 'tags',
-            'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'actions',
+            'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan',
+            'actions',
         )
         )
         default_columns = ('pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'ip_addresses')
         default_columns = ('pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'ip_addresses')
         row_attrs = {
         row_attrs = {

+ 8 - 0
netbox/virtualization/tests/test_api.py

@@ -4,6 +4,7 @@ from rest_framework import status
 from dcim.choices import InterfaceModeChoices
 from dcim.choices import InterfaceModeChoices
 from dcim.models import Site
 from dcim.models import Site
 from extras.models import ConfigTemplate
 from extras.models import ConfigTemplate
+from ipam.choices import VLANQinQRoleChoices
 from ipam.models import VLAN, VRF
 from ipam.models import VLAN, VRF
 from utilities.testing import APITestCase, APIViewTestCases, create_test_device, create_test_virtualmachine
 from utilities.testing import APITestCase, APIViewTestCases, create_test_device, create_test_virtualmachine
 from virtualization.choices import *
 from virtualization.choices import *
@@ -270,6 +271,7 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
             VLAN(name='VLAN 1', vid=1),
             VLAN(name='VLAN 1', vid=1),
             VLAN(name='VLAN 2', vid=2),
             VLAN(name='VLAN 2', vid=2),
             VLAN(name='VLAN 3', vid=3),
             VLAN(name='VLAN 3', vid=3),
+            VLAN(name='SVLAN 1', vid=1001, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
         )
         )
         VLAN.objects.bulk_create(vlans)
         VLAN.objects.bulk_create(vlans)
 
 
@@ -307,6 +309,12 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
                 'untagged_vlan': vlans[2].pk,
                 'untagged_vlan': vlans[2].pk,
                 'vrf': vrfs[2].pk,
                 'vrf': vrfs[2].pk,
             },
             },
+            {
+                'virtual_machine': virtualmachine.pk,
+                'name': 'Interface 7',
+                'mode': InterfaceModeChoices.MODE_Q_IN_Q,
+                'qinq_svlan': vlans[3].pk,
+            },
         ]
         ]
 
 
     def test_bulk_delete_child_interfaces(self):
     def test_bulk_delete_child_interfaces(self):

+ 21 - 3
netbox/virtualization/tests/test_filtersets.py

@@ -1,7 +1,9 @@
 from django.test import TestCase
 from django.test import TestCase
 
 
+from dcim.choices import InterfaceModeChoices
 from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
 from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
-from ipam.models import IPAddress, VLANTranslationPolicy, VRF
+from ipam.choices import VLANQinQRoleChoices
+from ipam.models import IPAddress, VLAN, VLANTranslationPolicy, VRF
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from utilities.testing import ChangeLoggedFilterSetTests, create_test_device
 from utilities.testing import ChangeLoggedFilterSetTests, create_test_device
 from virtualization.choices import *
 from virtualization.choices import *
@@ -528,7 +530,7 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
 class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
 class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = VMInterface.objects.all()
     queryset = VMInterface.objects.all()
     filterset = VMInterfaceFilterSet
     filterset = VMInterfaceFilterSet
-    ignore_fields = ('tagged_vlans', 'untagged_vlan',)
+    ignore_fields = ('tagged_vlans', 'untagged_vlan', 'qinq_svlan')
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -554,6 +556,13 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         )
         VRF.objects.bulk_create(vrfs)
         VRF.objects.bulk_create(vrfs)
 
 
+        vlans = (
+            VLAN(name='SVLAN 1', vid=1001, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
+            VLAN(name='SVLAN 2', vid=1002, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
+            VLAN(name='SVLAN 3', vid=1003, qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
+        )
+        VLAN.objects.bulk_create(vlans)
+
         vms = (
         vms = (
             VirtualMachine(name='Virtual Machine 1', cluster=clusters[0]),
             VirtualMachine(name='Virtual Machine 1', cluster=clusters[0]),
             VirtualMachine(name='Virtual Machine 2', cluster=clusters[1]),
             VirtualMachine(name='Virtual Machine 2', cluster=clusters[1]),
@@ -596,7 +605,9 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
                 mtu=300,
                 mtu=300,
                 mac_address='00-00-00-00-00-03',
                 mac_address='00-00-00-00-00-03',
                 vrf=vrfs[2],
                 vrf=vrfs[2],
-                description='foobar3'
+                description='foobar3',
+                mode=InterfaceModeChoices.MODE_Q_IN_Q,
+                qinq_svlan=vlans[0]
             ),
             ),
         )
         )
         VMInterface.objects.bulk_create(interfaces)
         VMInterface.objects.bulk_create(interfaces)
@@ -667,6 +678,13 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'description': ['foobar1', 'foobar2']}
         params = {'description': ['foobar1', 'foobar2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_vlan(self):
+        vlan = VLAN.objects.filter(qinq_role=VLANQinQRoleChoices.ROLE_SERVICE).first()
+        params = {'vlan_id': vlan.pk}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'vlan': vlan.vid}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
     def test_vlan_translation_policy(self):
     def test_vlan_translation_policy(self):
         vlan_translation_policies = VLANTranslationPolicy.objects.all()[:2]
         vlan_translation_policies = VLANTranslationPolicy.objects.all()[:2]
         params = {'vlan_translation_policy_id': [vlan_translation_policies[0].pk, vlan_translation_policies[1].pk]}
         params = {'vlan_translation_policy_id': [vlan_translation_policies[0].pk, vlan_translation_policies[1].pk]}