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

Closes #22462: Allow terminating an L2VPN to an IP address

Add IP addresses as an L2VPN termination type, e.g. for the far-end PE of a
pseudowire, with the pseudowire ID recorded as the L2VPN's identifier. An IP
address may terminate any number of L2VPNs, but each one only once, so the
unique constraint now covers the L2VPN as well; interfaces and VLANs keep
their one-L2VPN limit through model validation.
Brian Tiemann 1 день назад
Родитель
Сommit
d71bd0315b

+ 1 - 1
docs/features/l2vpn-overlay.md

@@ -2,4 +2,4 @@
 
 
 L2VPN and overlay networks, such as VXLAN and EVPN, can be defined in NetBox and tied to interfaces and VLANs. This allows for easy tracking of overlay assets and their relationships with underlay resources.
 L2VPN and overlay networks, such as VXLAN and EVPN, can be defined in NetBox and tied to interfaces and VLANs. This allows for easy tracking of overlay assets and their relationships with underlay resources.
 
 
-Each L2VPN instance has a type and optional unique identifier. Like VRFs, L2VPNs can also have import and export route targets assigned to them. Terminations can then be created to assign VLANs and/or device and virtual machine interfaces to the overlay.
+Each L2VPN instance has a type and optional unique identifier. Like VRFs, L2VPNs can also have import and export route targets assigned to them. Terminations can then be created to assign VLANs, device and virtual machine interfaces, and/or IP addresses (such as the far-end of a pseudowire) to the overlay.

+ 1 - 1
docs/models/vpn/l2vpn.md

@@ -1,6 +1,6 @@
 # L2VPN
 # L2VPN
 
 
-A L2VPN object is NetBox is a representation of a layer 2 bridge technology such as VXLAN, VPLS, or EPL. Each L2VPN can be identified by name as well as by an optional unique identifier (VNI would be an example). Once created, L2VPNs can be terminated to [interfaces](../dcim/interface.md) and [VLANs](../ipam/vlan.md).
+A L2VPN object is NetBox is a representation of a layer 2 bridge technology such as VXLAN, VPLS, or EPL. Each L2VPN can be identified by name as well as by an optional unique identifier (VNI would be an example). Once created, L2VPNs can be terminated to [interfaces](../dcim/interface.md), [VLANs](../ipam/vlan.md), and [IP addresses](../ipam/ipaddress.md).
 
 
 ## Fields
 ## Fields
 
 

+ 5 - 3
docs/models/vpn/l2vpntermination.md

@@ -1,6 +1,6 @@
 # L2VPN Termination
 # L2VPN Termination
 
 
-A L2VPN termination is the attachment of an [L2VPN](./l2vpn.md) to an [interface](../dcim/interface.md) or [VLAN](../ipam/vlan.md). Note that the L2VPNs of the following types may have only two terminations assigned to them:
+A L2VPN termination is the attachment of an [L2VPN](./l2vpn.md) to an [interface](../dcim/interface.md), [VLAN](../ipam/vlan.md), or [IP address](../ipam/ipaddress.md). Note that the L2VPNs of the following types may have only two terminations assigned to them:
 
 
 * VPWS
 * VPWS
 * EPL
 * EPL
@@ -13,6 +13,8 @@ A L2VPN termination is the attachment of an [L2VPN](./l2vpn.md) to an [interface
 
 
 The [L2VPN](./l2vpn.md) instance.
 The [L2VPN](./l2vpn.md) instance.
 
 
-### VLAN or Interface
+### VLAN, Interface, or IP Address
 
 
-The [VLAN](../ipam/vlan.md), [device interface](../dcim/interface.md), or [virtual machine interface](../virtualization/virtualmachine.md) attached to the L2VPN.
+The [VLAN](../ipam/vlan.md), [device interface](../dcim/interface.md), [virtual machine interface](../virtualization/virtualmachine.md), or [IP address](../ipam/ipaddress.md) attached to the L2VPN.
+
+An interface or VLAN can terminate only one L2VPN. An IP address can terminate any number of L2VPNs, but each L2VPN only once. For example, a pseudowire can be terminated to the IP address of its far-end provider edge (PE) device, with the pseudowire ID recorded as the L2VPN's identifier.

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

@@ -167,6 +167,7 @@ class IPAddressType(ContactsMixin, PrimaryObjectType):
     nat_inside: Annotated['IPAddressType', strawberry.lazy('ipam.graphql.types')] | None
     nat_inside: Annotated['IPAddressType', strawberry.lazy('ipam.graphql.types')] | None
     nat_outside: list[Annotated['IPAddressType', strawberry.lazy('ipam.graphql.types')]]
     nat_outside: list[Annotated['IPAddressType', strawberry.lazy('ipam.graphql.types')]]
     tunnel_terminations: list[Annotated['TunnelTerminationType', strawberry.lazy('vpn.graphql.types')]]
     tunnel_terminations: list[Annotated['TunnelTerminationType', strawberry.lazy('vpn.graphql.types')]]
+    l2vpn_terminations: list[Annotated['L2VPNTerminationType', strawberry.lazy('vpn.graphql.types')]]
     services: list[Annotated['ServiceType', strawberry.lazy('ipam.graphql.types')]]
     services: list[Annotated['ServiceType', strawberry.lazy('ipam.graphql.types')]]
 
 
     # Note that self is the Django model instance, so self.family resolves to the model's family property
     # Note that self is the Django model instance, so self.family resolves to the model's family property

+ 7 - 1
netbox/ipam/models/ip.py

@@ -1,5 +1,5 @@
 import netaddr
 import netaddr
-from django.contrib.contenttypes.fields import GenericForeignKey
+from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.postgres.indexes import GistIndex
 from django.contrib.postgres.indexes import GistIndex
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
@@ -1013,6 +1013,12 @@ class IPAddress(ContactsMixin, PrimaryModel):
         verbose_name=_('DNS name'),
         verbose_name=_('DNS name'),
         help_text=_('Hostname or FQDN (not case-sensitive)')
         help_text=_('Hostname or FQDN (not case-sensitive)')
     )
     )
+    l2vpn_terminations = GenericRelation(
+        to='vpn.L2VPNTermination',
+        content_type_field='assigned_object_type',
+        object_id_field='assigned_object_id',
+        related_query_name='ipaddress',
+    )
 
 
     objects = IPAddressManager()
     objects = IPAddressManager()
 
 

+ 6 - 0
netbox/ipam/views.py

@@ -1153,6 +1153,12 @@ class IPAddressView(generic.ObjectView):
                 filters={'ip_address_id': lambda ctx: ctx['object'].pk},
                 filters={'ip_address_id': lambda ctx: ctx['object'].pk},
                 title=_('Application services'),
                 title=_('Application services'),
             ),
             ),
+            ObjectsTablePanel(
+                'vpn.l2vpntermination',
+                filters={'ipaddress_id': lambda ctx: ctx['object'].pk},
+                exclude_columns=['assigned_object_type', 'assigned_object'],
+                title=_('L2VPN terminations'),
+            ),
         ],
         ],
     )
     )
 
 

+ 1 - 0
netbox/vpn/constants.py

@@ -2,6 +2,7 @@ from django.db.models import Q
 
 
 L2VPN_ASSIGNMENT_MODELS = Q(
 L2VPN_ASSIGNMENT_MODELS = Q(
     Q(app_label='dcim', model='interface') |
     Q(app_label='dcim', model='interface') |
+    Q(app_label='ipam', model='ipaddress') |
     Q(app_label='ipam', model='vlan') |
     Q(app_label='ipam', model='vlan') |
     Q(app_label='virtualization', model='vminterface')
     Q(app_label='virtualization', model='vminterface')
 )
 )

+ 11 - 0
netbox/vpn/filtersets.py

@@ -475,6 +475,17 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet):
         queryset=VLAN.objects.all(),
         queryset=VLAN.objects.all(),
         label=_('VLAN (ID)'),
         label=_('VLAN (ID)'),
     )
     )
+    ipaddress = django_filters.ModelMultipleChoiceFilter(
+        field_name='ipaddress__address',
+        queryset=IPAddress.objects.all(),
+        to_field_name='address',
+        label=_('IP address'),
+    )
+    ipaddress_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='ipaddress',
+        queryset=IPAddress.objects.all(),
+        label=_('IP address (ID)'),
+    )
     assigned_object_type_id = django_filters.ModelMultipleChoiceFilter(
     assigned_object_type_id = django_filters.ModelMultipleChoiceFilter(
         queryset=ObjectType.objects.all(),
         queryset=ObjectType.objects.all(),
         distinct=False,
         distinct=False,

+ 23 - 9
netbox/vpn/forms/bulk_import.py

@@ -312,10 +312,17 @@ class L2VPNTerminationImportForm(NetBoxModelImportForm):
         to_field_name='name',
         to_field_name='name',
         help_text=_('Assigned VLAN')
         help_text=_('Assigned VLAN')
     )
     )
+    ipaddress = CSVModelChoiceField(
+        label=_('IP address'),
+        queryset=IPAddress.objects.all(),
+        required=False,
+        to_field_name='address',
+        help_text=_('Assigned IP address, with mask (e.g. the far-end PE of a pseudowire)')
+    )
 
 
     class Meta:
     class Meta:
         model = L2VPNTermination
         model = L2VPNTermination
-        fields = ('l2vpn', 'device', 'virtual_machine', 'interface', 'vlan', 'tags')
+        fields = ('l2vpn', 'device', 'virtual_machine', 'interface', 'vlan', 'ipaddress', 'tags')
 
 
     def __init__(self, data=None, *args, **kwargs):
     def __init__(self, data=None, *args, **kwargs):
         super().__init__(data, *args, **kwargs)
         super().__init__(data, *args, **kwargs)
@@ -337,11 +344,18 @@ class L2VPNTerminationImportForm(NetBoxModelImportForm):
 
 
         if self.cleaned_data.get('device') and self.cleaned_data.get('virtual_machine'):
         if self.cleaned_data.get('device') and self.cleaned_data.get('virtual_machine'):
             raise ValidationError(_('Cannot import device and VM interface terminations simultaneously.'))
             raise ValidationError(_('Cannot import device and VM interface terminations simultaneously.'))
-        if not self.instance and not (self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')):
-            raise ValidationError(_('Each termination must specify either an interface or a VLAN.'))
-        if self.cleaned_data.get('interface') and self.cleaned_data.get('vlan'):
-            raise ValidationError(_('Cannot assign both an interface and a VLAN.'))
-
-        # if this is an update we might not have interface or vlan in the form data
-        if self.cleaned_data.get('interface') or self.cleaned_data.get('vlan'):
-            self.instance.assigned_object = self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')
+        assigned_objects = [
+            obj for obj in (
+                self.cleaned_data.get('interface'),
+                self.cleaned_data.get('vlan'),
+                self.cleaned_data.get('ipaddress'),
+            ) if obj
+        ]
+        if not self.instance and not assigned_objects:
+            raise ValidationError(_('Each termination must specify an interface, a VLAN, or an IP address.'))
+        if len(assigned_objects) > 1:
+            raise ValidationError(_('Cannot assign more than one of an interface, a VLAN, and an IP address.'))
+
+        # if this is an update we might not have an assigned object in the form data
+        if assigned_objects:
+            self.instance.assigned_object = assigned_objects[0]

+ 7 - 2
netbox/vpn/forms/filtersets.py

@@ -3,7 +3,7 @@ from django.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
 from dcim.models import Device, Region, Site
 from dcim.models import Device, Region, Site
-from ipam.models import VLAN, RouteTarget
+from ipam.models import VLAN, IPAddress, RouteTarget
 from netbox.forms import NetBoxModelFilterSetForm, OrganizationalModelFilterSetForm, PrimaryModelFilterSetForm
 from netbox.forms import NetBoxModelFilterSetForm, OrganizationalModelFilterSetForm, PrimaryModelFilterSetForm
 from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
 from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
 from utilities.forms.fields import (
 from utilities.forms.fields import (
@@ -259,7 +259,7 @@ class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm):
         FieldSet('filter_id', 'tag', 'l2vpn_id'),
         FieldSet('filter_id', 'tag', 'l2vpn_id'),
         FieldSet(
         FieldSet(
             'assigned_object_type_id', 'region_id', 'site_id', 'device_id', 'virtual_machine_id', 'vlan_id',
             'assigned_object_type_id', 'region_id', 'site_id', 'device_id', 'virtual_machine_id', 'vlan_id',
-            name=_('Assigned Object')
+            'ipaddress_id', name=_('Assigned Object')
         ),
         ),
     )
     )
     l2vpn_id = DynamicModelChoiceField(
     l2vpn_id = DynamicModelChoiceField(
@@ -314,4 +314,9 @@ class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm):
         },
         },
         label=_('Virtual Machine')
         label=_('Virtual Machine')
     )
     )
+    ipaddress_id = DynamicModelMultipleChoiceField(
+        queryset=IPAddress.objects.all(),
+        required=False,
+        label=_('IP Address')
+    )
     tag = TagFilterField(model)
     tag = TagFilterField(model)

+ 18 - 5
netbox/vpn/forms/model_forms.py

@@ -506,6 +506,13 @@ class L2VPNTerminationForm(NetBoxModelForm):
         selector=True,
         selector=True,
         label=_('Interface')
         label=_('Interface')
     )
     )
+    ipaddress = DynamicModelChoiceField(
+        queryset=IPAddress.objects.all(),
+        required=False,
+        selector=True,
+        label=_('IP Address'),
+        help_text=_('For example, the far-end PE of a pseudowire')
+    )
 
 
     fieldsets = (
     fieldsets = (
         FieldSet(
         FieldSet(
@@ -514,6 +521,7 @@ class L2VPNTerminationForm(NetBoxModelForm):
                 FieldSet('vlan', name=_('VLAN')),
                 FieldSet('vlan', name=_('VLAN')),
                 FieldSet('interface', name=_('Device')),
                 FieldSet('interface', name=_('Device')),
                 FieldSet('vminterface', name=_('Virtual Machine')),
                 FieldSet('vminterface', name=_('Virtual Machine')),
+                FieldSet('ipaddress', name=_('IP Address')),
             ),
             ),
             'tags',
             'tags',
         ),
         ),
@@ -534,6 +542,8 @@ class L2VPNTerminationForm(NetBoxModelForm):
                 initial['vlan'] = instance.assigned_object
                 initial['vlan'] = instance.assigned_object
             elif type(instance.assigned_object) is VMInterface:
             elif type(instance.assigned_object) is VMInterface:
                 initial['vminterface'] = instance.assigned_object
                 initial['vminterface'] = instance.assigned_object
+            elif type(instance.assigned_object) is IPAddress:
+                initial['ipaddress'] = instance.assigned_object
             kwargs['initial'] = initial
             kwargs['initial'] = initial
 
 
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
@@ -544,10 +554,13 @@ class L2VPNTerminationForm(NetBoxModelForm):
         interface = self.cleaned_data.get('interface')
         interface = self.cleaned_data.get('interface')
         vminterface = self.cleaned_data.get('vminterface')
         vminterface = self.cleaned_data.get('vminterface')
         vlan = self.cleaned_data.get('vlan')
         vlan = self.cleaned_data.get('vlan')
+        ipaddress = self.cleaned_data.get('ipaddress')
 
 
-        if not (interface or vminterface or vlan):
-            raise ValidationError(_('A termination must specify an interface or VLAN.'))
-        if len([x for x in (interface, vminterface, vlan) if x]) > 1:
-            raise ValidationError(_('A termination can only have one terminating object (an interface or VLAN).'))
+        if not (interface or vminterface or vlan or ipaddress):
+            raise ValidationError(_('A termination must specify an interface, VLAN, or IP address.'))
+        if len([x for x in (interface, vminterface, vlan, ipaddress) if x]) > 1:
+            raise ValidationError(
+                _('A termination can only have one terminating object (an interface, VLAN, or IP address).')
+            )
 
 
-        self.instance.assigned_object = interface or vminterface or vlan
+        self.instance.assigned_object = interface or vminterface or vlan or ipaddress

+ 3 - 1
netbox/vpn/graphql/types.py

@@ -5,7 +5,7 @@ import strawberry_django
 
 
 from dcim.models import Interface
 from dcim.models import Interface
 from extras.graphql.mixins import ContactsMixin, CustomFieldsMixin, TagsMixin
 from extras.graphql.mixins import ContactsMixin, CustomFieldsMixin, TagsMixin
-from ipam.models import VLAN
+from ipam.models import VLAN, IPAddress
 from netbox.graphql.optimization import build_gfk_prefetch
 from netbox.graphql.optimization import build_gfk_prefetch
 from netbox.graphql.types import (
 from netbox.graphql.types import (
     NetBoxObjectType,
     NetBoxObjectType,
@@ -162,12 +162,14 @@ class L2VPNTerminationType(NetBoxObjectType):
                 Interface,
                 Interface,
                 VMInterface,
                 VMInterface,
                 VLAN,
                 VLAN,
+                IPAddress,
             ],
             ],
         ),
         ),
         only=['assigned_object_type', 'assigned_object_id'],
         only=['assigned_object_type', 'assigned_object_id'],
     )
     )
     def assigned_object(self) -> Annotated[
     def assigned_object(self) -> Annotated[
         Annotated['InterfaceType', strawberry.lazy('dcim.graphql.types')]
         Annotated['InterfaceType', strawberry.lazy('dcim.graphql.types')]
+        | Annotated['IPAddressType', strawberry.lazy('ipam.graphql.types')]
         | Annotated['VLANType', strawberry.lazy('ipam.graphql.types')]
         | Annotated['VLANType', strawberry.lazy('ipam.graphql.types')]
         | Annotated['VMInterfaceType', strawberry.lazy('virtualization.graphql.types')],
         | Annotated['VMInterfaceType', strawberry.lazy('virtualization.graphql.types')],
         strawberry.union('L2VPNAssignmentType'),
         strawberry.union('L2VPNAssignmentType'),

+ 26 - 0
netbox/vpn/migrations/0014_l2vpntermination_unique_per_l2vpn.py

@@ -0,0 +1,26 @@
+# Generated by Django 6.1.1 on 2026-11-02 19:40
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('extras', '0145_taggeditem_object_id_bigint'),
+        ('vpn', '0013_consolidate_unique_constraints'),
+    ]
+
+    operations = [
+        migrations.RemoveConstraint(
+            model_name='l2vpntermination',
+            name='vpn_l2vpntermination_assigned_object',
+        ),
+        migrations.AddConstraint(
+            model_name='l2vpntermination',
+            constraint=models.UniqueConstraint(
+                fields=('l2vpn', 'assigned_object_type', 'assigned_object_id'),
+                name='vpn_l2vpntermination_l2vpn_assigned_object',
+            ),
+        ),
+    ]

+ 12 - 5
netbox/vpn/models/l2vpn.py

@@ -5,6 +5,7 @@ from django.utils.functional import cached_property
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
 from core.models import ObjectType
 from core.models import ObjectType
+from ipam.models import IPAddress
 from netbox.models import NetBoxModel, PrimaryModel
 from netbox.models import NetBoxModel, PrimaryModel
 from netbox.models.features import ContactsMixin
 from netbox.models.features import ContactsMixin
 from vpn.choices import L2VPNStatusChoices, L2VPNTypeChoices
 from vpn.choices import L2VPNStatusChoices, L2VPNTypeChoices
@@ -109,8 +110,8 @@ class L2VPNTermination(NetBoxModel):
         ordering = ('l2vpn',)
         ordering = ('l2vpn',)
         constraints = (
         constraints = (
             models.UniqueConstraint(
             models.UniqueConstraint(
-                fields=('assigned_object_type', 'assigned_object_id'),
-                name='vpn_l2vpntermination_assigned_object'
+                fields=('l2vpn', 'assigned_object_type', 'assigned_object_id'),
+                name='vpn_l2vpntermination_l2vpn_assigned_object'
             ),
             ),
         )
         )
         verbose_name = _('L2VPN termination')
         verbose_name = _('L2VPN termination')
@@ -122,8 +123,8 @@ class L2VPNTermination(NetBoxModel):
         return super().__str__()
         return super().__str__()
 
 
     def clean(self):
     def clean(self):
-        # Only check is assigned_object is set.  Required otherwise we have an Integrity Error thrown.
-        if self.assigned_object:
+        # An IP address (e.g. a pseudowire's far-end PE) may terminate many L2VPNs, but other objects only one.
+        if self.assigned_object and not isinstance(self.assigned_object, IPAddress):
             obj_id = self.assigned_object.pk
             obj_id = self.assigned_object.pk
             obj_type = ObjectType.objects.get_for_model(self.assigned_object)
             obj_type = ObjectType.objects.get_for_model(self.assigned_object)
             terminations = L2VPNTermination.objects.filter(assigned_object_id=obj_id, assigned_object_type=obj_type)
             terminations = L2VPNTermination.objects.filter(assigned_object_id=obj_id, assigned_object_type=obj_type)
@@ -148,6 +149,10 @@ class L2VPNTermination(NetBoxModel):
     @property
     @property
     def assigned_object_parent(self):
     def assigned_object_parent(self):
         obj_type = ObjectType.objects.get_for_model(self.assigned_object)
         obj_type = ObjectType.objects.get_for_model(self.assigned_object)
+        if obj_type.model == 'ipaddress':
+            # The device or virtual machine whose interface the IP address is assigned to, if any
+            interface = self.assigned_object.assigned_object
+            return getattr(interface, 'parent_object', None)
         if obj_type.model == 'vminterface':
         if obj_type.model == 'vminterface':
             return self.assigned_object.virtual_machine
             return self.assigned_object.virtual_machine
         if obj_type.model == 'interface':
         if obj_type.model == 'interface':
@@ -158,4 +163,6 @@ class L2VPNTermination(NetBoxModel):
 
 
     @property
     @property
     def assigned_object_site(self):
     def assigned_object_site(self):
-        return self.assigned_object_parent.site
+        if parent := self.assigned_object_parent:
+            return parent.site
+        return None

+ 1 - 1
netbox/vpn/tests/query_counts.json

@@ -11,7 +11,7 @@
   "ipsecproposal:list_objects_with_permission": 16,
   "ipsecproposal:list_objects_with_permission": 16,
   "l2vpn:api_list_objects": 14,
   "l2vpn:api_list_objects": 14,
   "l2vpn:list_objects_with_permission": 16,
   "l2vpn:list_objects_with_permission": 16,
-  "l2vpntermination:api_list_objects": 15,
+  "l2vpntermination:api_list_objects": 16,
   "l2vpntermination:list_objects_with_permission": 21,
   "l2vpntermination:list_objects_with_permission": 21,
   "tunnel:api_list_objects": 13,
   "tunnel:api_list_objects": 13,
   "tunnel:list_objects_with_permission": 17,
   "tunnel:list_objects_with_permission": 17,

+ 8 - 5
netbox/vpn/tests/test_api.py

@@ -3,7 +3,7 @@ from rest_framework import status
 
 
 from dcim.choices import InterfaceTypeChoices
 from dcim.choices import InterfaceTypeChoices
 from dcim.models import Interface
 from dcim.models import Interface
-from ipam.models import VLAN
+from ipam.models import VLAN, IPAddress
 from utilities.testing import APITestCase, APIViewTestCases, create_test_device
 from utilities.testing import APITestCase, APIViewTestCases, create_test_device
 from vpn.choices import *
 from vpn.choices import *
 from vpn.models import *
 from vpn.models import *
@@ -676,10 +676,12 @@ class L2VPNTerminationTestCase(APIViewTestCases.APIViewTestCase):
         )
         )
         L2VPN.objects.bulk_create(l2vpns)
         L2VPN.objects.bulk_create(l2vpns)
 
 
+        ip_address = IPAddress.objects.create(address='192.0.2.1/32')
+
         l2vpnterminations = (
         l2vpnterminations = (
             L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]),
             L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]),
             L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[1]),
             L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[1]),
-            L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[2])
+            L2VPNTermination(l2vpn=l2vpns[0], assigned_object=ip_address)
         )
         )
         L2VPNTermination.objects.bulk_create(l2vpnterminations)
         L2VPNTermination.objects.bulk_create(l2vpnterminations)
 
 
@@ -695,9 +697,10 @@ class L2VPNTerminationTestCase(APIViewTestCases.APIViewTestCase):
                 'assigned_object_id': vlans[4].pk,
                 'assigned_object_id': vlans[4].pk,
             },
             },
             {
             {
-                'l2vpn': l2vpns[0].pk,
-                'assigned_object_type': 'ipam.vlan',
-                'assigned_object_id': vlans[5].pk,
+                # The same IP address may terminate another L2VPN
+                'l2vpn': l2vpns[2].pk,
+                'assigned_object_type': 'ipam.ipaddress',
+                'assigned_object_id': ip_address.pk,
             },
             },
         ]
         ]
 
 

+ 16 - 0
netbox/vpn/tests/test_filtersets.py

@@ -887,6 +887,13 @@ class L2VPNTerminationTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         )
         )
         L2VPN.objects.bulk_create(l2vpns)
         L2VPN.objects.bulk_create(l2vpns)
 
 
+        ip_addresses = (
+            IPAddress(address='192.0.2.1/32'),
+            IPAddress(address='192.0.2.2/32'),
+            IPAddress(address='192.0.2.3/32'),
+        )
+        IPAddress.objects.bulk_create(ip_addresses)
+
         l2vpnterminations = (
         l2vpnterminations = (
             L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]),
             L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]),
             L2VPNTermination(l2vpn=l2vpns[1], assigned_object=vlans[1]),
             L2VPNTermination(l2vpn=l2vpns[1], assigned_object=vlans[1]),
@@ -897,6 +904,8 @@ class L2VPNTerminationTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
             L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vminterfaces[0]),
             L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vminterfaces[0]),
             L2VPNTermination(l2vpn=l2vpns[1], assigned_object=vminterfaces[1]),
             L2VPNTermination(l2vpn=l2vpns[1], assigned_object=vminterfaces[1]),
             L2VPNTermination(l2vpn=l2vpns[2], assigned_object=vminterfaces[2]),
             L2VPNTermination(l2vpn=l2vpns[2], assigned_object=vminterfaces[2]),
+            L2VPNTermination(l2vpn=l2vpns[2], assigned_object=ip_addresses[0]),
+            L2VPNTermination(l2vpn=l2vpns[2], assigned_object=ip_addresses[1]),
         )
         )
         L2VPNTermination.objects.bulk_create(l2vpnterminations)
         L2VPNTermination.objects.bulk_create(l2vpnterminations)
 
 
@@ -928,6 +937,13 @@ class L2VPNTerminationTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'vlan': ['VLAN 1', 'VLAN 2']}
         params = {'vlan': ['VLAN 1', 'VLAN 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_ipaddress(self):
+        ip_addresses = IPAddress.objects.all()[:2]
+        params = {'ipaddress_id': [ip_addresses[0].pk, ip_addresses[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'ipaddress': ['192.0.2.1/32', '192.0.2.2/32']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_site(self):
     def test_site(self):
         site = Site.objects.all().first()
         site = Site.objects.all().first()
         params = {'site_id': [site.pk]}
         params = {'site_id': [site.pk]}

+ 41 - 1
netbox/vpn/tests/test_models.py

@@ -2,7 +2,7 @@ from django.core.exceptions import ValidationError
 from django.test import TestCase
 from django.test import TestCase
 
 
 from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site
 from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site
-from ipam.models import VLAN
+from ipam.models import VLAN, IPAddress
 from vpn.models import *
 from vpn.models import *
 
 
 
 
@@ -77,3 +77,43 @@ class L2VPNTerminationTestCase(TestCase):
         L2VPNTermination.objects.create(l2vpn=l2vpn, assigned_object=vlan)
         L2VPNTermination.objects.create(l2vpn=l2vpn, assigned_object=vlan)
         duplicate = L2VPNTermination(l2vpn=l2vpn, assigned_object=vlan)
         duplicate = L2VPNTermination(l2vpn=l2vpn, assigned_object=vlan)
         self.assertRaises(ValidationError, duplicate.clean)
         self.assertRaises(ValidationError, duplicate.clean)
+
+    def test_ipaddress_terminates_multiple_l2vpns(self):
+        ip_address = IPAddress.objects.create(address='192.0.2.1/32')
+        l2vpns = L2VPN.objects.all()[:2]
+
+        L2VPNTermination.objects.create(l2vpn=l2vpns[0], assigned_object=ip_address)
+        termination = L2VPNTermination(l2vpn=l2vpns[1], assigned_object=ip_address)
+        termination.full_clean()
+        termination.save()
+
+        self.assertEqual(ip_address.l2vpn_terminations.count(), 2)
+
+    def test_duplicate_ipaddress_terminations(self):
+        ip_address = IPAddress.objects.create(address='192.0.2.1/32')
+        l2vpn = L2VPN.objects.first()
+
+        L2VPNTermination.objects.create(l2vpn=l2vpn, assigned_object=ip_address)
+        duplicate = L2VPNTermination(l2vpn=l2vpn, assigned_object=ip_address)
+        self.assertRaises(ValidationError, duplicate.full_clean)
+
+    def test_ipaddress_termination_parent(self):
+        interface = Interface.objects.first()
+        assigned_ip = IPAddress.objects.create(address='192.0.2.1/32', assigned_object=interface)
+        unassigned_ip = IPAddress.objects.create(address='192.0.2.2/32')
+        l2vpn = L2VPN.objects.first()
+
+        termination = L2VPNTermination.objects.create(l2vpn=l2vpn, assigned_object=assigned_ip)
+        self.assertEqual(termination.assigned_object_parent, interface.device)
+        self.assertEqual(termination.assigned_object_site, interface.device.site)
+
+        termination = L2VPNTermination.objects.create(l2vpn=l2vpn, assigned_object=unassigned_ip)
+        self.assertIsNone(termination.assigned_object_parent)
+        self.assertIsNone(termination.assigned_object_site)
+
+    def test_delete_ipaddress_deletes_terminations(self):
+        ip_address = IPAddress.objects.create(address='192.0.2.1/32')
+        termination = L2VPNTermination.objects.create(l2vpn=L2VPN.objects.first(), assigned_object=ip_address)
+
+        ip_address.delete()
+        self.assertFalse(L2VPNTermination.objects.filter(pk=termination.pk).exists())

+ 7 - 5
netbox/vpn/tests/test_views.py

@@ -1,6 +1,6 @@
 from dcim.choices import InterfaceTypeChoices
 from dcim.choices import InterfaceTypeChoices
 from dcim.models import Interface
 from dcim.models import Interface
-from ipam.models import VLAN, RouteTarget
+from ipam.models import VLAN, IPAddress, RouteTarget
 from utilities.testing import ViewTestCases, create_tags, create_test_device
 from utilities.testing import ViewTestCases, create_tags, create_test_device
 from vpn.choices import *
 from vpn.choices import *
 from vpn.models import *
 from vpn.models import *
@@ -664,11 +664,13 @@ class L2VPNTerminationTestCase(
             'interface': interface.pk,
             'interface': interface.pk,
         }
         }
 
 
+        IPAddress.objects.create(address='192.0.2.1/32')
+
         cls.csv_data = (
         cls.csv_data = (
-            "l2vpn,vlan",
-            "L2VPN 1,Vlan 4",
-            "L2VPN 1,Vlan 5",
-            "L2VPN 1,Vlan 6",
+            "l2vpn,vlan,ipaddress",
+            "L2VPN 1,Vlan 4,",
+            "L2VPN 1,Vlan 5,",
+            "L2VPN 1,,192.0.2.1/32",
         )
         )
 
 
         cls.csv_update_data = (
         cls.csv_update_data = (