-
+ {# Include a copy-to-clipboard button #}
+ {% elif 'data-clipboard' in field.field.widget.attrs %}
+
{# Default field rendering #}
{% else %}
{{ field }}
diff --git a/netbox/utilities/templates/helpers/utilization_graph.html b/netbox/utilities/templates/helpers/utilization_graph.html
index 967ac8a87..c642f4c34 100644
--- a/netbox/utilities/templates/helpers/utilization_graph.html
+++ b/netbox/utilities/templates/helpers/utilization_graph.html
@@ -1,3 +1,4 @@
+{% load l10n %}
{% if utilization >= 35 %}{{ utilization|floatformat:1 }}%{% endif %}
diff --git a/netbox/utilities/templatetags/builtins/filters.py b/netbox/utilities/templatetags/builtins/filters.py
index a52a38116..d18524965 100644
--- a/netbox/utilities/templatetags/builtins/filters.py
+++ b/netbox/utilities/templatetags/builtins/filters.py
@@ -8,6 +8,7 @@ from django.contrib.contenttypes.models import ContentType
from django.utils.html import escape
from django.utils.safestring import mark_safe
from markdown import markdown
+from markdown.extensions.tables import TableExtension
from netbox.config import get_config
from utilities.markdown import StrikethroughExtension
@@ -163,7 +164,12 @@ def render_markdown(value):
return ''
# Render Markdown
- html = markdown(value, extensions=['def_list', 'fenced_code', 'tables', StrikethroughExtension()])
+ html = markdown(value, extensions=[
+ 'def_list',
+ 'fenced_code',
+ StrikethroughExtension(),
+ TableExtension(use_align_attribute=True),
+ ])
# If the string is not empty wrap it in rendered-markdown to style tables
if html:
diff --git a/netbox/utilities/templatetags/builtins/tags.py b/netbox/utilities/templatetags/builtins/tags.py
index 35aec1000..dc5d75f48 100644
--- a/netbox/utilities/templatetags/builtins/tags.py
+++ b/netbox/utilities/templatetags/builtins/tags.py
@@ -1,6 +1,7 @@
from django import template
from django.http import QueryDict
+from extras.choices import CustomFieldTypeChoices
from utilities.utils import dict_to_querydict
__all__ = (
@@ -38,6 +39,11 @@ def customfield_value(customfield, value):
customfield: A CustomField instance
value: The custom field value applied to an object
"""
+ if value:
+ if customfield.type == CustomFieldTypeChoices.TYPE_SELECT:
+ value = customfield.get_choice_label(value)
+ elif customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
+ value = [customfield.get_choice_label(v) for v in value]
return {
'customfield': customfield,
'value': value,
@@ -81,13 +87,14 @@ def checkmark(value, show_false=True, true='Yes', false='No'):
@register.inclusion_tag('builtins/copy_content.html')
-def copy_content(target, prefix=None, color='primary'):
+def copy_content(target, prefix=None, color='primary', classes=None):
"""
Display a copy button to copy the content of a field.
"""
return {
'target': f'#{prefix or ""}{target}',
- 'color': f'btn-{color}'
+ 'color': f'btn-{color}',
+ 'classes': classes or '',
}
diff --git a/netbox/utilities/templatetags/mptt.py b/netbox/utilities/templatetags/mptt.py
new file mode 100644
index 000000000..783c2654f
--- /dev/null
+++ b/netbox/utilities/templatetags/mptt.py
@@ -0,0 +1,20 @@
+from django import template
+from django.utils.safestring import mark_safe
+
+register = template.Library()
+
+
+@register.simple_tag()
+def nested_tree(obj):
+ """
+ Renders the entire hierarchy of a recursively-nested object (such as Region or SiteGroup).
+ """
+ if not obj:
+ return mark_safe('—')
+
+ nodes = obj.get_ancestors(include_self=True)
+ return mark_safe(
+ ' / '.join(
+ f'
{node}' for node in nodes
+ )
+ )
diff --git a/netbox/utilities/templatetags/navigation.py b/netbox/utilities/templatetags/navigation.py
index 4a229e952..7534d7034 100644
--- a/netbox/utilities/templatetags/navigation.py
+++ b/netbox/utilities/templatetags/navigation.py
@@ -26,11 +26,14 @@ def nav(context: Context) -> Dict:
for group in menu.groups:
items = []
for item in group.items:
- if user.has_perms(item.permissions):
- buttons = [
- button for button in item.buttons if user.has_perms(button.permissions)
- ]
- items.append((item, buttons))
+ if not user.has_perms(item.permissions):
+ continue
+ if item.staff_only and not user.is_staff:
+ continue
+ buttons = [
+ button for button in item.buttons if user.has_perms(button.permissions)
+ ]
+ items.append((item, buttons))
if items:
groups.append((group, items))
if groups:
diff --git a/netbox/extras/templatetags/plugins.py b/netbox/utilities/templatetags/plugins.py
similarity index 98%
rename from netbox/extras/templatetags/plugins.py
rename to netbox/utilities/templatetags/plugins.py
index 560d15e01..c429bed5f 100644
--- a/netbox/extras/templatetags/plugins.py
+++ b/netbox/utilities/templatetags/plugins.py
@@ -2,7 +2,7 @@ from django import template as template_
from django.conf import settings
from django.utils.safestring import mark_safe
-from extras.plugins import PluginTemplateExtension
+from netbox.plugins import PluginTemplateExtension
from netbox.registry import registry
register = template_.Library()
diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py
index 3c2dc3c45..0a84c5d1b 100644
--- a/netbox/utilities/testing/views.py
+++ b/netbox/utilities/testing/views.py
@@ -11,7 +11,7 @@ from extras.choices import ObjectChangeActionChoices
from extras.models import ObjectChange
from netbox.models.features import ChangeLoggingMixin
from users.models import ObjectPermission
-from utilities.choices import ImportFormatChoices
+from utilities.choices import CSVDelimiterChoices, ImportFormatChoices
from .base import ModelTestCase
from .utils import disable_warnings, post_data
@@ -580,7 +580,8 @@ class ViewTestCases:
def test_bulk_import_objects_without_permission(self):
data = {
'data': self._get_csv_data(),
- 'format': 'csv',
+ 'format': ImportFormatChoices.CSV,
+ 'csv_delimiter': CSVDelimiterChoices.AUTO,
}
# Test GET without permission
@@ -597,7 +598,8 @@ class ViewTestCases:
initial_count = self._get_queryset().count()
data = {
'data': self._get_csv_data(),
- 'format': 'csv',
+ 'format': ImportFormatChoices.CSV,
+ 'csv_delimiter': CSVDelimiterChoices.AUTO,
}
# Assign model-level permission
@@ -626,6 +628,7 @@ class ViewTestCases:
data = {
'format': ImportFormatChoices.CSV,
'data': csv_data,
+ 'csv_delimiter': CSVDelimiterChoices.AUTO,
}
# Assign model-level permission
@@ -658,7 +661,8 @@ class ViewTestCases:
initial_count = self._get_queryset().count()
data = {
'data': self._get_csv_data(),
- 'format': 'csv',
+ 'format': ImportFormatChoices.CSV,
+ 'csv_delimiter': CSVDelimiterChoices.AUTO,
}
# Assign constrained permission
diff --git a/netbox/utilities/tests/test_counters.py b/netbox/utilities/tests/test_counters.py
index e9561c91b..014c758e9 100644
--- a/netbox/utilities/tests/test_counters.py
+++ b/netbox/utilities/tests/test_counters.py
@@ -1,7 +1,11 @@
-from django.test import TestCase
+from django.contrib.contenttypes.models import ContentType
+from django.test import override_settings
+from django.urls import reverse
from dcim.models import *
-from utilities.testing.utils import create_test_device
+from users.models import ObjectPermission
+from utilities.testing.base import TestCase
+from utilities.testing.utils import create_test_device, create_test_user
class CountersTest(TestCase):
@@ -10,7 +14,6 @@ class CountersTest(TestCase):
"""
@classmethod
def setUpTestData(cls):
-
# Create devices
device1 = create_test_device('Device 1')
device2 = create_test_device('Device 2')
@@ -29,13 +32,25 @@ class CountersTest(TestCase):
self.assertEqual(device1.interface_count, 2)
self.assertEqual(device2.interface_count, 2)
- Interface.objects.create(device=device1, name='Interface 5')
+ interface1 = Interface.objects.create(device=device1, name='Interface 5')
Interface.objects.create(device=device2, name='Interface 6')
device1.refresh_from_db()
device2.refresh_from_db()
self.assertEqual(device1.interface_count, 3)
self.assertEqual(device2.interface_count, 3)
+ # test saving an existing object - counter should not change
+ interface1.save()
+ device1.refresh_from_db()
+ self.assertEqual(device1.interface_count, 3)
+
+ # test save where tracked object FK back pointer is None
+ vc = VirtualChassis.objects.create(name='Virtual Chassis 1')
+ device1.virtual_chassis = vc
+ device1.save()
+ vc.refresh_from_db()
+ self.assertEqual(vc.member_count, 1)
+
def test_interface_count_deletion(self):
"""
When a tracked object (Interface) is deleted the tracking counter should be updated.
@@ -67,3 +82,25 @@ class CountersTest(TestCase):
device2.refresh_from_db()
self.assertEqual(device1.interface_count, 1)
self.assertEqual(device2.interface_count, 3)
+
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+ def test_mptt_child_delete(self):
+ device1, device2 = Device.objects.all()
+ inventory_item1 = InventoryItem.objects.create(device=device1, name='Inventory Item 1')
+ inventory_item2 = InventoryItem.objects.create(device=device1, name='Inventory Item 2', parent=inventory_item1)
+ device1.refresh_from_db()
+ self.assertEqual(device1.inventory_item_count, 2)
+
+ # Setup bulk_delete for the inventory items
+ self.add_permissions('dcim.delete_inventoryitem')
+ pk_list = device1.inventoryitems.values_list('pk', flat=True)
+ data = {
+ 'pk': pk_list,
+ 'confirm': True,
+ '_confirm': True, # Form button
+ }
+
+ # Try POST with model-level permission
+ self.client.post(reverse("dcim:inventoryitem_bulk_delete"), data)
+ device1.refresh_from_db()
+ self.assertEqual(device1.inventory_item_count, 0)
diff --git a/netbox/utilities/tests/test_filters.py b/netbox/utilities/tests/test_filters.py
index 80d611375..6caeb9d14 100644
--- a/netbox/utilities/tests/test_filters.py
+++ b/netbox/utilities/tests/test_filters.py
@@ -86,6 +86,10 @@ class DummyModel(models.Model):
charfield = models.CharField(
max_length=10
)
+ numberfield = models.IntegerField(
+ blank=True,
+ null=True
+ )
choicefield = models.IntegerField(
choices=(('A', 1), ('B', 2), ('C', 3))
)
@@ -108,6 +112,7 @@ class BaseFilterSetTest(TestCase):
"""
class DummyFilterSet(BaseFilterSet):
charfield = django_filters.CharFilter()
+ numberfield = django_filters.NumberFilter()
macaddressfield = MACAddressFilter()
modelchoicefield = django_filters.ModelChoiceFilter(
field_name='integerfield', # We're pretending this is a ForeignKey field
@@ -132,6 +137,7 @@ class BaseFilterSetTest(TestCase):
model = DummyModel
fields = (
'charfield',
+ 'numberfield',
'choicefield',
'datefield',
'datetimefield',
@@ -171,6 +177,25 @@ class BaseFilterSetTest(TestCase):
self.assertEqual(self.filters['charfield__iew'].exclude, False)
self.assertEqual(self.filters['charfield__niew'].lookup_expr, 'iendswith')
self.assertEqual(self.filters['charfield__niew'].exclude, True)
+ self.assertEqual(self.filters['charfield__empty'].lookup_expr, 'empty')
+ self.assertEqual(self.filters['charfield__empty'].exclude, False)
+
+ def test_number_filter(self):
+ self.assertIsInstance(self.filters['numberfield'], django_filters.NumberFilter)
+ self.assertEqual(self.filters['numberfield'].lookup_expr, 'exact')
+ self.assertEqual(self.filters['numberfield'].exclude, False)
+ self.assertEqual(self.filters['numberfield__n'].lookup_expr, 'exact')
+ self.assertEqual(self.filters['numberfield__n'].exclude, True)
+ self.assertEqual(self.filters['numberfield__lt'].lookup_expr, 'lt')
+ self.assertEqual(self.filters['numberfield__lt'].exclude, False)
+ self.assertEqual(self.filters['numberfield__lte'].lookup_expr, 'lte')
+ self.assertEqual(self.filters['numberfield__lte'].exclude, False)
+ self.assertEqual(self.filters['numberfield__gt'].lookup_expr, 'gt')
+ self.assertEqual(self.filters['numberfield__gt'].exclude, False)
+ self.assertEqual(self.filters['numberfield__gte'].lookup_expr, 'gte')
+ self.assertEqual(self.filters['numberfield__gte'].exclude, False)
+ self.assertEqual(self.filters['numberfield__empty'].lookup_expr, 'isnull')
+ self.assertEqual(self.filters['numberfield__empty'].exclude, False)
def test_mac_address_filter(self):
self.assertIsInstance(self.filters['macaddressfield'], MACAddressFilter)
diff --git a/netbox/utilities/tests/test_forms.py b/netbox/utilities/tests/test_forms.py
index 79ba3f4d8..d014d4bbd 100644
--- a/netbox/utilities/tests/test_forms.py
+++ b/netbox/utilities/tests/test_forms.py
@@ -3,6 +3,7 @@ from django.test import TestCase
from utilities.choices import ImportFormatChoices
from utilities.forms.bulk_import import BulkImportForm
+from utilities.forms.forms import BulkRenameForm
from utilities.forms.utils import expand_alphanumeric_pattern, expand_ipaddress_pattern
@@ -331,3 +332,49 @@ class ImportFormTest(TestCase):
form._detect_format('')
with self.assertRaises(forms.ValidationError):
form._detect_format('?')
+
+ def test_csv_delimiters(self):
+ form = BulkImportForm()
+
+ data = (
+ "a,b,c\n"
+ "1,2,3\n"
+ "4,5,6\n"
+ )
+ self.assertEqual(form._clean_csv(data, delimiter=','), [
+ {'a': '1', 'b': '2', 'c': '3'},
+ {'a': '4', 'b': '5', 'c': '6'},
+ ])
+
+ data = (
+ "a;b;c\n"
+ "1;2;3\n"
+ "4;5;6\n"
+ )
+ self.assertEqual(form._clean_csv(data, delimiter=';'), [
+ {'a': '1', 'b': '2', 'c': '3'},
+ {'a': '4', 'b': '5', 'c': '6'},
+ ])
+
+ data = (
+ "a\tb\tc\n"
+ "1\t2\t3\n"
+ "4\t5\t6\n"
+ )
+ self.assertEqual(form._clean_csv(data, delimiter='\t'), [
+ {'a': '1', 'b': '2', 'c': '3'},
+ {'a': '4', 'b': '5', 'c': '6'},
+ ])
+
+
+class BulkRenameFormTest(TestCase):
+ def test_no_strip_whitespace(self):
+ # Tests to make sure Bulk Rename Form isn't stripping whitespaces
+ # See: https://github.com/netbox-community/netbox/issues/13791
+ form = BulkRenameForm(data={
+ "find": " hello ",
+ "replace": " world "
+ })
+ self.assertTrue(form.is_valid())
+ self.assertEqual(form.cleaned_data["find"], " hello ")
+ self.assertEqual(form.cleaned_data["replace"], " world ")
diff --git a/netbox/utilities/tests/test_request.py b/netbox/utilities/tests/test_request.py
new file mode 100644
index 000000000..69f677323
--- /dev/null
+++ b/netbox/utilities/tests/test_request.py
@@ -0,0 +1,28 @@
+from django.test import TestCase, RequestFactory
+
+from netaddr import IPAddress
+from utilities.request import get_client_ip
+
+
+class GetClientIPTests(TestCase):
+ def setUp(self):
+ self.factory = RequestFactory()
+
+ def test_ipv4_address(self):
+ request = self.factory.get('/', HTTP_X_FORWARDED_FOR='192.168.1.1')
+ self.assertEqual(get_client_ip(request), IPAddress('192.168.1.1'))
+ request = self.factory.get('/', HTTP_X_FORWARDED_FOR='192.168.1.1:8080')
+ self.assertEqual(get_client_ip(request), IPAddress('192.168.1.1'))
+
+ def test_ipv6_address(self):
+ request = self.factory.get('/', HTTP_X_FORWARDED_FOR='2001:db8::8a2e:370:7334')
+ self.assertEqual(get_client_ip(request), IPAddress('2001:db8::8a2e:370:7334'))
+ request = self.factory.get('/', HTTP_X_FORWARDED_FOR='[2001:db8::8a2e:370:7334]')
+ self.assertEqual(get_client_ip(request), IPAddress('2001:db8::8a2e:370:7334'))
+ request = self.factory.get('/', HTTP_X_FORWARDED_FOR='[2001:db8::8a2e:370:7334]:8080')
+ self.assertEqual(get_client_ip(request), IPAddress('2001:db8::8a2e:370:7334'))
+
+ def test_invalid_ip_address(self):
+ request = self.factory.get('/', HTTP_X_FORWARDED_FOR='invalid_ip')
+ with self.assertRaises(ValueError):
+ get_client_ip(request)
diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py
index 9524e242c..f3f8c7c50 100644
--- a/netbox/utilities/utils.py
+++ b/netbox/utilities/utils.py
@@ -8,7 +8,7 @@ from itertools import count, groupby
import bleach
from django.contrib.contenttypes.models import ContentType
from django.core import serializers
-from django.db.models import Count, OuterRef, Subquery
+from django.db.models import Count, ManyToOneRel, OuterRef, Subquery
from django.db.models.functions import Coalesce
from django.http import QueryDict
from django.utils import timezone
@@ -19,9 +19,9 @@ from jinja2.sandbox import SandboxedEnvironment
from mptt.models import MPTTModel
from dcim.choices import CableLengthUnitChoices, WeightUnitChoices
-from extras.plugins import PluginConfig
from extras.utils import is_taggable
from netbox.config import get_config
+from netbox.plugins import PluginConfig
from urllib.parse import urlencode
from utilities.constants import HTTP_REQUEST_META_SAFE_COPY
@@ -144,15 +144,23 @@ def count_related(model, field):
return Coalesce(subquery, 0)
-def serialize_object(obj, resolve_tags=True, extra=None):
+def serialize_object(obj, resolve_tags=True, extra=None, exclude=None):
"""
Return a generic JSON representation of an object using Django's built-in serializer. (This is used for things like
change logging, not the REST API.) Optionally include a dictionary to supplement the object data. A list of keys
can be provided to exclude them from the returned dictionary. Private fields (prefaced with an underscore) are
implicitly excluded.
+
+ Args:
+ obj: The object to serialize
+ resolve_tags: If true, any assigned tags will be represented by their names
+ extra: Any additional data to include in the serialized output. Keys provided in this mapping will
+ override object attributes.
+ exclude: An iterable of attributes to exclude from the serialized output
"""
json_str = serializers.serialize('json', [obj])
data = json.loads(json_str)[0]['fields']
+ exclude = exclude or []
# Exclude any MPTTModel fields
if issubclass(obj.__class__, MPTTModel):
@@ -169,16 +177,15 @@ def serialize_object(obj, resolve_tags=True, extra=None):
tags = getattr(obj, '_tags', None) or obj.tags.all()
data['tags'] = sorted([tag.name for tag in tags])
+ # Skip excluded and private (prefixes with an underscore) attributes
+ for key in list(data.keys()):
+ if key in exclude or (isinstance(key, str) and key.startswith('_')):
+ data.pop(key)
+
# Append any extra data
if extra is not None:
data.update(extra)
- # Copy keys to list to avoid 'dictionary changed size during iteration' exception
- for key in list(data):
- # Private fields shouldn't be logged in the object change
- if isinstance(key, str) and key.startswith('_'):
- data.pop(key)
-
return data
@@ -567,3 +574,20 @@ def local_now():
Return the current date & time in the system timezone.
"""
return localtime(timezone.now())
+
+
+def get_related_models(model, ordered=True):
+ """
+ Return a list of all models which have a ForeignKey to the given model and the name of the field. For example,
+ `get_related_models(Tenant)` will return all models which have a ForeignKey relationship to Tenant.
+ """
+ related_models = [
+ (field.related_model, field.remote_field.name)
+ for field in model._meta.related_objects
+ if type(field) is ManyToOneRel
+ ]
+
+ if ordered:
+ return sorted(related_models, key=lambda x: x[0]._meta.verbose_name.lower())
+
+ return related_models
diff --git a/netbox/virtualization/api/nested_serializers.py b/netbox/virtualization/api/nested_serializers.py
index 8c3f57c1d..afb7e39a1 100644
--- a/netbox/virtualization/api/nested_serializers.py
+++ b/netbox/virtualization/api/nested_serializers.py
@@ -2,12 +2,13 @@ from drf_spectacular.utils import extend_schema_serializer
from rest_framework import serializers
from netbox.api.serializers import WritableNestedSerializer
-from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
+from virtualization.models import *
__all__ = [
'NestedClusterGroupSerializer',
'NestedClusterSerializer',
'NestedClusterTypeSerializer',
+ 'NestedVirtualDiskSerializer',
'NestedVMInterfaceSerializer',
'NestedVirtualMachineSerializer',
]
@@ -72,3 +73,12 @@ class NestedVMInterfaceSerializer(WritableNestedSerializer):
class Meta:
model = VMInterface
fields = ['id', 'url', 'display', 'virtual_machine', 'name']
+
+
+class NestedVirtualDiskSerializer(WritableNestedSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualdisk-detail')
+ virtual_machine = NestedVirtualMachineSerializer(read_only=True)
+
+ class Meta:
+ model = VirtualDisk
+ fields = ['id', 'url', 'display', 'virtual_machine', 'name', 'size']
diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py
index c9fa559aa..7ed36388b 100644
--- a/netbox/virtualization/api/serializers.py
+++ b/netbox/virtualization/api/serializers.py
@@ -6,15 +6,14 @@ from dcim.api.nested_serializers import (
)
from dcim.choices import InterfaceModeChoices
from extras.api.nested_serializers import NestedConfigTemplateSerializer
-from ipam.api.nested_serializers import (
- NestedIPAddressSerializer, NestedL2VPNTerminationSerializer, NestedVLANSerializer, NestedVRFSerializer,
-)
+from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer
from ipam.models import VLAN
from netbox.api.fields import ChoiceField, SerializedPKRelatedField
from netbox.api.serializers import NetBoxModelSerializer
from tenancy.api.nested_serializers import NestedTenantSerializer
from virtualization.choices import *
-from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
+from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualDisk, VirtualMachine, VMInterface
+from vpn.api.nested_serializers import NestedL2VPNTerminationSerializer
from .nested_serializers import *
@@ -84,6 +83,7 @@ class VirtualMachineSerializer(NetBoxModelSerializer):
# Counter fields
interface_count = serializers.IntegerField(read_only=True)
+ virtual_disk_count = serializers.IntegerField(read_only=True)
class Meta:
model = VirtualMachine
@@ -91,7 +91,7 @@ class VirtualMachineSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform',
'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments',
'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated',
- 'interface_count',
+ 'interface_count', 'virtual_disk_count',
]
validators = []
@@ -104,7 +104,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform',
'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments',
'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
- 'interface_count',
+ 'interface_count', 'virtual_disk_count',
]
@extend_schema_field(serializers.JSONField(allow_null=True))
@@ -159,3 +159,19 @@ class VMInterfaceSerializer(NetBoxModelSerializer):
})
return super().validate(data)
+
+
+#
+# Virtual Disk
+#
+
+class VirtualDiskSerializer(NetBoxModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualdisk-detail')
+ virtual_machine = NestedVirtualMachineSerializer()
+
+ class Meta:
+ model = VirtualDisk
+ fields = [
+ 'id', 'url', 'virtual_machine', 'name', 'description', 'size', 'tags', 'custom_fields', 'created',
+ 'last_updated',
+ ]
diff --git a/netbox/virtualization/api/urls.py b/netbox/virtualization/api/urls.py
index 2ceeb8ce6..ce71605a1 100644
--- a/netbox/virtualization/api/urls.py
+++ b/netbox/virtualization/api/urls.py
@@ -13,6 +13,7 @@ router.register('clusters', views.ClusterViewSet)
# VirtualMachines
router.register('virtual-machines', views.VirtualMachineViewSet)
router.register('interfaces', views.VMInterfaceViewSet)
+router.register('virtual-disks', views.VirtualDiskViewSet)
app_name = 'virtualization-api'
urlpatterns = router.urls
diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py
index 5b9cf4117..3ba2bb97f 100644
--- a/netbox/virtualization/api/views.py
+++ b/netbox/virtualization/api/views.py
@@ -1,11 +1,12 @@
from rest_framework.routers import APIRootView
from dcim.models import Device
-from extras.api.mixins import ConfigContextQuerySetMixin
+from extras.api.mixins import ConfigContextQuerySetMixin, RenderConfigMixin
from netbox.api.viewsets import NetBoxModelViewSet
+from utilities.query_functions import CollateAsChar
from utilities.utils import count_related
from virtualization import filtersets
-from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
+from virtualization.models import *
from . import serializers
@@ -52,9 +53,10 @@ class ClusterViewSet(NetBoxModelViewSet):
# Virtual machines
#
-class VirtualMachineViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
+class VirtualMachineViewSet(ConfigContextQuerySetMixin, RenderConfigMixin, NetBoxModelViewSet):
queryset = VirtualMachine.objects.prefetch_related(
- 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags'
+ 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'config_template',
+ 'tags', 'virtualdisks',
)
filterset_class = filtersets.VirtualMachineFilterSet
@@ -87,3 +89,16 @@ class VMInterfaceViewSet(NetBoxModelViewSet):
serializer_class = serializers.VMInterfaceSerializer
filterset_class = filtersets.VMInterfaceFilterSet
brief_prefetch_fields = ['virtual_machine']
+
+ def get_bulk_destroy_queryset(self):
+ # Ensure child interfaces are deleted prior to their parents
+ return self.get_queryset().order_by('virtual_machine', 'parent', CollateAsChar('_name'))
+
+
+class VirtualDiskViewSet(NetBoxModelViewSet):
+ queryset = VirtualDisk.objects.prefetch_related(
+ 'virtual_machine', 'tags',
+ )
+ serializer_class = serializers.VirtualDiskSerializer
+ filterset_class = filtersets.VirtualDiskFilterSet
+ brief_prefetch_fields = ['virtual_machine']
diff --git a/netbox/virtualization/apps.py b/netbox/virtualization/apps.py
index 8db943ea1..f0af9a163 100644
--- a/netbox/virtualization/apps.py
+++ b/netbox/virtualization/apps.py
@@ -5,7 +5,7 @@ class VirtualizationConfig(AppConfig):
name = 'virtualization'
def ready(self):
- from . import search
+ from . import search, signals
from .models import VirtualMachine
from utilities.counters import connect_counters
diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py
index 571dbe64b..78f6566d3 100644
--- a/netbox/virtualization/filtersets.py
+++ b/netbox/virtualization/filtersets.py
@@ -6,16 +6,18 @@ from dcim.filtersets import CommonInterfaceFilterSet
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
from extras.filtersets import LocalConfigContextFilterSet
from extras.models import ConfigTemplate
+from ipam.filtersets import PrimaryIPFilterSet
from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
from .choices import *
-from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
+from .models import *
__all__ = (
'ClusterFilterSet',
'ClusterGroupFilterSet',
'ClusterTypeFilterSet',
+ 'VirtualDiskFilterSet',
'VirtualMachineFilterSet',
'VMInterfaceFilterSet',
)
@@ -99,13 +101,14 @@ class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
class Meta:
model = Cluster
- fields = ['id', 'name']
+ fields = ['id', 'name', 'description']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
+ Q(description__icontains=value) |
Q(comments__icontains=value)
)
@@ -114,7 +117,8 @@ class VirtualMachineFilterSet(
NetBoxModelFilterSet,
TenancyFilterSet,
ContactModelFilterSet,
- LocalConfigContextFilterSet
+ LocalConfigContextFilterSet,
+ PrimaryIPFilterSet,
):
status = django_filters.MultipleChoiceFilter(
choices=VirtualMachineStatusChoices,
@@ -236,13 +240,14 @@ class VirtualMachineFilterSet(
class Meta:
model = VirtualMachine
- fields = ['id', 'cluster', 'vcpus', 'memory', 'disk']
+ fields = ['id', 'cluster', 'vcpus', 'memory', 'disk', 'description']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
+ Q(description__icontains=value) |
Q(comments__icontains=value) |
Q(primary_ip4__address__startswith=value) |
Q(primary_ip6__address__startswith=value)
@@ -303,3 +308,29 @@ class VMInterfaceFilterSet(NetBoxModelFilterSet, CommonInterfaceFilterSet):
Q(name__icontains=value) |
Q(description__icontains=value)
)
+
+
+class VirtualDiskFilterSet(NetBoxModelFilterSet):
+ virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
+ field_name='virtual_machine',
+ queryset=VirtualMachine.objects.all(),
+ label=_('Virtual machine (ID)'),
+ )
+ virtual_machine = django_filters.ModelMultipleChoiceFilter(
+ field_name='virtual_machine__name',
+ queryset=VirtualMachine.objects.all(),
+ to_field_name='name',
+ label=_('Virtual machine'),
+ )
+
+ class Meta:
+ model = VirtualDisk
+ fields = ['id', 'name', 'size', 'description']
+
+ def search(self, queryset, name, value):
+ if not value.strip():
+ return queryset
+ return queryset.filter(
+ Q(name__icontains=value) |
+ Q(description__icontains=value)
+ )
diff --git a/netbox/virtualization/forms/bulk_create.py b/netbox/virtualization/forms/bulk_create.py
index 7153453ec..a4ad867d4 100644
--- a/netbox/virtualization/forms/bulk_create.py
+++ b/netbox/virtualization/forms/bulk_create.py
@@ -3,9 +3,10 @@ from django.utils.translation import gettext_lazy as _
from utilities.forms import BootstrapMixin, form_from_model
from utilities.forms.fields import ExpandableNameField
-from virtualization.models import VMInterface, VirtualMachine
+from virtualization.models import VirtualDisk, VMInterface, VirtualMachine
__all__ = (
+ 'VirtualDiskBulkCreateForm',
'VMInterfaceBulkCreateForm',
)
@@ -30,3 +31,10 @@ class VMInterfaceBulkCreateForm(
VirtualMachineBulkAddComponentForm
):
replication_fields = ('name',)
+
+
+class VirtualDiskBulkCreateForm(
+ form_from_model(VirtualDisk, ['size', 'description', 'tags']),
+ VirtualMachineBulkAddComponentForm
+):
+ replication_fields = ('name',)
diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py
index a33ffac53..b76d8a160 100644
--- a/netbox/virtualization/forms/bulk_edit.py
+++ b/netbox/virtualization/forms/bulk_edit.py
@@ -18,6 +18,8 @@ __all__ = (
'ClusterBulkEditForm',
'ClusterGroupBulkEditForm',
'ClusterTypeBulkEditForm',
+ 'VirtualDiskBulkEditForm',
+ 'VirtualDiskBulkRenameForm',
'VirtualMachineBulkEditForm',
'VMInterfaceBulkEditForm',
'VMInterfaceBulkRenameForm',
@@ -294,9 +296,10 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm):
# Check interface sites. First interface should set site, further interfaces will either continue the
# loop or reset back to no site and break the loop.
for interface in interfaces:
+ vm_site = interface.virtual_machine.site or interface.virtual_machine.cluster.site
if site is None:
- site = interface.virtual_machine.cluster.site
- elif interface.virtual_machine.cluster.site is not site:
+ site = vm_site
+ elif vm_site is not site:
site = None
break
@@ -315,3 +318,35 @@ class VMInterfaceBulkRenameForm(BulkRenameForm):
queryset=VMInterface.objects.all(),
widget=forms.MultipleHiddenInput()
)
+
+
+class VirtualDiskBulkEditForm(NetBoxModelBulkEditForm):
+ virtual_machine = forms.ModelChoiceField(
+ label=_('Virtual machine'),
+ queryset=VirtualMachine.objects.all(),
+ required=False,
+ disabled=True,
+ widget=forms.HiddenInput()
+ )
+ size = forms.IntegerField(
+ required=False,
+ label=_('Size (GB)')
+ )
+ description = forms.CharField(
+ label=_('Description'),
+ max_length=100,
+ required=False
+ )
+
+ model = VirtualDisk
+ fieldsets = (
+ (None, ('size', 'description')),
+ )
+ nullable_fields = ('description',)
+
+
+class VirtualDiskBulkRenameForm(BulkRenameForm):
+ pk = forms.ModelMultipleChoiceField(
+ queryset=VirtualDisk.objects.all(),
+ widget=forms.MultipleHiddenInput()
+ )
diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py
index 04fe2d7ae..5d44ddceb 100644
--- a/netbox/virtualization/forms/bulk_import.py
+++ b/netbox/virtualization/forms/bulk_import.py
@@ -14,6 +14,7 @@ __all__ = (
'ClusterImportForm',
'ClusterGroupImportForm',
'ClusterTypeImportForm',
+ 'VirtualDiskImportForm',
'VirtualMachineImportForm',
'VMInterfaceImportForm',
)
@@ -199,3 +200,17 @@ class VMInterfaceImportForm(NetBoxModelImportForm):
return True
else:
return self.cleaned_data['enabled']
+
+
+class VirtualDiskImportForm(NetBoxModelImportForm):
+ virtual_machine = CSVModelChoiceField(
+ label=_('Virtual machine'),
+ queryset=VirtualMachine.objects.all(),
+ to_field_name='name'
+ )
+
+ class Meta:
+ model = VirtualDisk
+ fields = (
+ 'virtual_machine', 'name', 'size', 'description', 'tags'
+ )
diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py
index 99ac0cb77..5b0d097f8 100644
--- a/netbox/virtualization/forms/filtersets.py
+++ b/netbox/virtualization/forms/filtersets.py
@@ -4,18 +4,20 @@ from django.utils.translation import gettext_lazy as _
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
from extras.forms import LocalConfigContextFilterForm
from extras.models import ConfigTemplate
-from ipam.models import L2VPN, VRF
+from ipam.models import VRF
from netbox.forms import NetBoxModelFilterSetForm
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES
from utilities.forms.fields import DynamicModelMultipleChoiceField, TagFilterField
from virtualization.choices import *
from virtualization.models import *
+from vpn.models import L2VPN
__all__ = (
'ClusterFilterForm',
'ClusterGroupFilterForm',
'ClusterTypeFilterForm',
+ 'VirtualDiskFilterForm',
'VirtualMachineFilterForm',
'VMInterfaceFilterForm',
)
@@ -44,6 +46,7 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
)
+ selector_fields = ('filter_id', 'q', 'group_id')
type_id = DynamicModelMultipleChoiceField(
queryset=ClusterType.objects.all(),
required=False,
@@ -186,6 +189,7 @@ class VMInterfaceFilterForm(NetBoxModelFilterSetForm):
(_('Virtual Machine'), ('cluster_id', 'virtual_machine_id')),
(_('Attributes'), ('enabled', 'mac_address', 'vrf_id', 'l2vpn_id')),
)
+ selector_fields = ('filter_id', 'q', 'virtual_machine_id')
cluster_id = DynamicModelMultipleChoiceField(
queryset=Cluster.objects.all(),
required=False,
@@ -221,3 +225,23 @@ class VMInterfaceFilterForm(NetBoxModelFilterSetForm):
label=_('L2VPN')
)
tag = TagFilterField(model)
+
+
+class VirtualDiskFilterForm(NetBoxModelFilterSetForm):
+ model = VirtualDisk
+ fieldsets = (
+ (None, ('q', 'filter_id', 'tag')),
+ (_('Virtual Machine'), ('virtual_machine_id',)),
+ (_('Attributes'), ('size',)),
+ )
+ virtual_machine_id = DynamicModelMultipleChoiceField(
+ queryset=VirtualMachine.objects.all(),
+ required=False,
+ label=_('Virtual machine')
+ )
+ size = forms.IntegerField(
+ label=_('Size (GB)'),
+ required=False,
+ min_value=1
+ )
+ tag = TagFilterField(model)
diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py
index 21dbc895a..cbbf5ea66 100644
--- a/netbox/virtualization/forms/model_forms.py
+++ b/netbox/virtualization/forms/model_forms.py
@@ -22,6 +22,7 @@ __all__ = (
'ClusterGroupForm',
'ClusterRemoveDevicesForm',
'ClusterTypeForm',
+ 'VirtualDiskForm',
'VirtualMachineForm',
'VMInterfaceForm',
)
@@ -151,8 +152,12 @@ class ClusterAddDevicesForm(BootstrapMixin, forms.Form):
for device in self.cleaned_data.get('devices', []):
if device.site != self.cluster.site:
raise ValidationError({
- 'devices': _("{} belongs to a different site ({}) than the cluster ({})").format(
- device, device.site, self.cluster.site
+ 'devices': _(
+ "{device} belongs to a different site ({device_site}) than the cluster ({cluster_site})"
+ ).format(
+ device=device,
+ device_site=device.site,
+ cluster_site=self.cluster.site
)
})
@@ -200,7 +205,8 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
platform = DynamicModelChoiceField(
label=_('Platform'),
queryset=Platform.objects.all(),
- required=False
+ required=False,
+ selector=True
)
local_context_data = JSONField(
required=False,
@@ -235,6 +241,11 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
if self.instance.pk:
+ # Disable the disk field if one or more VirtualDisks have been created
+ if self.instance.virtualdisks.exists():
+ self.fields['disk'].widget.attrs['disabled'] = True
+ self.fields['disk'].help_text = _("Disk size is managed via the attachment of virtual disks.")
+
# Compile list of choices for primary IPv4 and IPv6 addresses
for family in [4, 6]:
ip_choices = [(None, '---------')]
@@ -271,12 +282,26 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
self.fields['primary_ip6'].widget.attrs['readonly'] = True
-class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm):
+#
+# Virtual machine components
+#
+
+class VMComponentForm(NetBoxModelForm):
virtual_machine = DynamicModelChoiceField(
label=_('Virtual machine'),
queryset=VirtualMachine.objects.all(),
selector=True
)
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ # Disable reassignment of VirtualMachine when editing an existing instance
+ if self.instance.pk:
+ self.fields['virtual_machine'].disabled = True
+
+
+class VMInterfaceForm(InterfaceCommonForm, VMComponentForm):
parent = DynamicModelChoiceField(
queryset=VMInterface.objects.all(),
required=False,
@@ -343,9 +368,15 @@ class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm):
'mode': HTMXSelect(),
}
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- # Disable reassignment of VirtualMachine when editing an existing instance
- if self.instance.pk:
- self.fields['virtual_machine'].disabled = True
+class VirtualDiskForm(VMComponentForm):
+
+ fieldsets = (
+ (_('Disk'), ('virtual_machine', 'name', 'size', 'description', 'tags')),
+ )
+
+ class Meta:
+ model = VirtualDisk
+ fields = [
+ 'virtual_machine', 'name', 'size', 'description', 'tags',
+ ]
diff --git a/netbox/virtualization/forms/object_create.py b/netbox/virtualization/forms/object_create.py
index 3ea374039..2f6844a5c 100644
--- a/netbox/virtualization/forms/object_create.py
+++ b/netbox/virtualization/forms/object_create.py
@@ -1,8 +1,9 @@
from django.utils.translation import gettext_lazy as _
from utilities.forms.fields import ExpandableNameField
-from .model_forms import VMInterfaceForm
+from .model_forms import VirtualDiskForm, VMInterfaceForm
__all__ = (
+ 'VirtualDiskCreateForm',
'VMInterfaceCreateForm',
)
@@ -15,3 +16,13 @@ class VMInterfaceCreateForm(VMInterfaceForm):
class Meta(VMInterfaceForm.Meta):
exclude = ('name',)
+
+
+class VirtualDiskCreateForm(VirtualDiskForm):
+ name = ExpandableNameField(
+ label=_('Name'),
+ )
+ replication_fields = ('name',)
+
+ class Meta(VirtualDiskForm.Meta):
+ exclude = ('name',)
diff --git a/netbox/virtualization/graphql/schema.py b/netbox/virtualization/graphql/schema.py
index 88e6aac64..1461faaeb 100644
--- a/netbox/virtualization/graphql/schema.py
+++ b/netbox/virtualization/graphql/schema.py
@@ -36,3 +36,9 @@ class VirtualizationQuery(graphene.ObjectType):
def resolve_vm_interface_list(root, info, **kwargs):
return gql_query_optimizer(models.VMInterface.objects.all(), info)
+
+ virtual_disk = ObjectField(VirtualDiskType)
+ virtual_disk_list = ObjectListField(VirtualDiskType)
+
+ def resolve_virtual_disk_list(root, info, **kwargs):
+ return gql_query_optimizer(models.VirtualDisk.objects.all(), info)
diff --git a/netbox/virtualization/graphql/types.py b/netbox/virtualization/graphql/types.py
index 96b0fc875..9b97e1dc9 100644
--- a/netbox/virtualization/graphql/types.py
+++ b/netbox/virtualization/graphql/types.py
@@ -8,6 +8,7 @@ __all__ = (
'ClusterType',
'ClusterGroupType',
'ClusterTypeType',
+ 'VirtualDiskType',
'VirtualMachineType',
'VMInterfaceType',
)
@@ -54,3 +55,14 @@ class VMInterfaceType(IPAddressesMixin, ComponentObjectType):
def resolve_mode(self, info):
return self.mode or None
+
+
+class VirtualDiskType(ComponentObjectType):
+
+ class Meta:
+ model = models.VirtualDisk
+ fields = '__all__'
+ filterset_class = filtersets.VirtualDiskFilterSet
+
+ def resolve_mode(self, info):
+ return self.mode or None
diff --git a/netbox/virtualization/migrations/0035_virtualmachine_interface_count.py b/netbox/virtualization/migrations/0035_virtualmachine_interface_count.py
index 5f52d32e0..abed09d7e 100644
--- a/netbox/virtualization/migrations/0035_virtualmachine_interface_count.py
+++ b/netbox/virtualization/migrations/0035_virtualmachine_interface_count.py
@@ -2,17 +2,13 @@ from django.db import migrations
from django.db.models import Count
import utilities.fields
+from utilities.counters import update_counts
def populate_virtualmachine_counts(apps, schema_editor):
VirtualMachine = apps.get_model('virtualization', 'VirtualMachine')
- vms = list(VirtualMachine.objects.annotate(_interface_count=Count('interfaces', distinct=True)))
-
- for vm in vms:
- vm.interface_count = vm._interface_count
-
- VirtualMachine.objects.bulk_update(vms, ['interface_count'])
+ update_counts(VirtualMachine, 'interface_count', 'interfaces')
class Migration(migrations.Migration):
diff --git a/netbox/virtualization/migrations/0037_protect_child_interfaces.py b/netbox/virtualization/migrations/0037_protect_child_interfaces.py
new file mode 100644
index 000000000..ab6cf0cb3
--- /dev/null
+++ b/netbox/virtualization/migrations/0037_protect_child_interfaces.py
@@ -0,0 +1,19 @@
+# Generated by Django 4.2.6 on 2023-10-20 11:48
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('virtualization', '0036_virtualmachine_config_template'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='vminterface',
+ name='parent',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='child_interfaces', to='virtualization.vminterface'),
+ ),
+ ]
diff --git a/netbox/virtualization/migrations/0038_virtualdisk.py b/netbox/virtualization/migrations/0038_virtualdisk.py
new file mode 100644
index 000000000..59d45c975
--- /dev/null
+++ b/netbox/virtualization/migrations/0038_virtualdisk.py
@@ -0,0 +1,50 @@
+from django.db import migrations, models
+import django.db.models.deletion
+import taggit.managers
+import utilities.fields
+import utilities.json
+import utilities.ordering
+import utilities.query_functions
+import utilities.tracking
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('extras', '0099_cachedvalue_ordering'),
+ ('virtualization', '0037_protect_child_interfaces'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='virtualmachine',
+ name='virtual_disk_count',
+ field=utilities.fields.CounterCacheField(default=0, editable=False, to_field='virtual_machine', to_model='virtualization.VirtualDisk'),
+ ),
+ migrations.CreateModel(
+ name='VirtualDisk',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+ ('created', models.DateTimeField(auto_now_add=True, null=True)),
+ ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+ ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+ ('name', models.CharField(max_length=64)),
+ ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface)),
+ ('description', models.CharField(blank=True, max_length=200)),
+ ('size', models.PositiveIntegerField()),
+ ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+ ('virtual_machine', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='virtualization.virtualmachine')),
+ ],
+ options={
+ 'verbose_name': 'virtual disk',
+ 'verbose_name_plural': 'virtual disks',
+ 'ordering': ('virtual_machine', utilities.query_functions.CollateAsChar('_name')),
+ 'abstract': False,
+ },
+ bases=(models.Model, utilities.tracking.TrackingModelMixin),
+ ),
+ migrations.AddConstraint(
+ model_name='virtualdisk',
+ constraint=models.UniqueConstraint(fields=('virtual_machine', 'name'), name='virtualization_virtualdisk_unique_virtual_machine_name'),
+ ),
+ ]
diff --git a/netbox/virtualization/models/clusters.py b/netbox/virtualization/models/clusters.py
index 6c8fd0c4b..f8acc4c36 100644
--- a/netbox/virtualization/models/clusters.py
+++ b/netbox/virtualization/models/clusters.py
@@ -135,10 +135,9 @@ class Cluster(ContactsMixin, PrimaryModel):
# If the Cluster is assigned to a Site, verify that all host Devices belong to that Site.
if self.pk and self.site:
- nonsite_devices = Device.objects.filter(cluster=self).exclude(site=self.site).count()
- if nonsite_devices:
+ if nonsite_devices := Device.objects.filter(cluster=self).exclude(site=self.site).count():
raise ValidationError({
- 'site': _("{} devices are assigned as hosts for this cluster but are not in site {}").format(
- nonsite_devices, self.site
- )
+ 'site': _(
+ "{count} devices are assigned as hosts for this cluster but are not in site {site}"
+ ).format(count=nonsite_devices, site=self.site)
})
diff --git a/netbox/virtualization/models/virtualmachines.py b/netbox/virtualization/models/virtualmachines.py
index eb6c2a8b0..233d51d63 100644
--- a/netbox/virtualization/models/virtualmachines.py
+++ b/netbox/virtualization/models/virtualmachines.py
@@ -2,7 +2,7 @@ from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import models
-from django.db.models import Q
+from django.db.models import Q, Sum
from django.db.models.functions import Lower
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
@@ -21,6 +21,7 @@ from utilities.tracking import TrackingModelMixin
from virtualization.choices import *
__all__ = (
+ 'VirtualDisk',
'VirtualMachine',
'VMInterface',
)
@@ -130,6 +131,10 @@ class VirtualMachine(ContactsMixin, RenderConfigMixin, ConfigContextModel, Prima
to_model='virtualization.VMInterface',
to_field='virtual_machine'
)
+ virtual_disk_count = CounterCacheField(
+ to_model='virtualization.VirtualDisk',
+ to_field='virtual_machine'
+ )
objects = ConfigContextModelQuerySet.as_manager()
@@ -192,6 +197,19 @@ class VirtualMachine(ContactsMixin, RenderConfigMixin, ConfigContextModel, Prima
).format(device=self.device, cluster=self.cluster)
})
+ # Validate aggregate disk size
+ if self.pk:
+ total_disk = self.virtualdisks.aggregate(Sum('size', default=0))['size__sum']
+ if total_disk and self.disk is None:
+ self.disk = total_disk
+ elif total_disk and self.disk != total_disk:
+ raise ValidationError({
+ 'disk': _(
+ "The specified disk size ({size}) must match the aggregate size of assigned virtual disks "
+ "({total_size})."
+ ).format(size=self.disk, total_size=total_disk)
+ })
+
# Validate primary IP addresses
interfaces = self.interfaces.all() if self.pk else None
for family in (4, 6):
@@ -236,11 +254,19 @@ class VirtualMachine(ContactsMixin, RenderConfigMixin, ConfigContextModel, Prima
return None
-class VMInterface(NetBoxModel, BaseInterface, TrackingModelMixin):
+#
+# VM components
+#
+
+
+class ComponentModel(NetBoxModel):
+ """
+ An abstract model inherited by any model which has a parent VirtualMachine.
+ """
virtual_machine = models.ForeignKey(
to='virtualization.VirtualMachine',
on_delete=models.CASCADE,
- related_name='interfaces'
+ related_name='%(class)ss'
)
name = models.CharField(
verbose_name=_('name'),
@@ -257,6 +283,42 @@ class VMInterface(NetBoxModel, BaseInterface, TrackingModelMixin):
max_length=200,
blank=True
)
+
+ class Meta:
+ abstract = True
+ ordering = ('virtual_machine', CollateAsChar('_name'))
+ constraints = (
+ models.UniqueConstraint(
+ fields=('virtual_machine', 'name'),
+ name='%(app_label)s_%(class)s_unique_virtual_machine_name'
+ ),
+ )
+
+ def __str__(self):
+ return self.name
+
+ def to_objectchange(self, action):
+ objectchange = super().to_objectchange(action)
+ objectchange.related_object = self.virtual_machine
+ return objectchange
+
+ @property
+ def parent_object(self):
+ return self.virtual_machine
+
+
+class VMInterface(ComponentModel, BaseInterface, TrackingModelMixin):
+ virtual_machine = models.ForeignKey(
+ to='virtualization.VirtualMachine',
+ on_delete=models.CASCADE,
+ related_name='interfaces' # Override ComponentModel
+ )
+ _name = NaturalOrderingField(
+ target_field='name',
+ naturalize_function=naturalize_interface,
+ max_length=100,
+ blank=True
+ )
untagged_vlan = models.ForeignKey(
to='ipam.VLAN',
on_delete=models.SET_NULL,
@@ -291,27 +353,23 @@ class VMInterface(NetBoxModel, BaseInterface, TrackingModelMixin):
object_id_field='interface_id',
related_query_name='+'
)
+ tunnel_terminations = GenericRelation(
+ to='vpn.TunnelTermination',
+ content_type_field='termination_type',
+ object_id_field='termination_id',
+ related_query_name='vminterface',
+ )
l2vpn_terminations = GenericRelation(
- to='ipam.L2VPNTermination',
+ to='vpn.L2VPNTermination',
content_type_field='assigned_object_type',
object_id_field='assigned_object_id',
related_query_name='vminterface',
)
- class Meta:
- ordering = ('virtual_machine', CollateAsChar('_name'))
- constraints = (
- models.UniqueConstraint(
- fields=('virtual_machine', 'name'),
- name='%(app_label)s_%(class)s_unique_virtual_machine_name'
- ),
- )
+ class Meta(ComponentModel.Meta):
verbose_name = _('interface')
verbose_name_plural = _('interfaces')
- def __str__(self):
- return self.name
-
def get_absolute_url(self):
return reverse('virtualization:vminterface', kwargs={'pk': self.pk})
@@ -359,15 +417,19 @@ class VMInterface(NetBoxModel, BaseInterface, TrackingModelMixin):
).format(untagged_vlan=self.untagged_vlan)
})
- def to_objectchange(self, action):
- objectchange = super().to_objectchange(action)
- objectchange.related_object = self.virtual_machine
- return objectchange
-
- @property
- def parent_object(self):
- return self.virtual_machine
-
@property
def l2vpn_termination(self):
return self.l2vpn_terminations.first()
+
+
+class VirtualDisk(ComponentModel, TrackingModelMixin):
+ size = models.PositiveIntegerField(
+ verbose_name=_('size (GB)'),
+ )
+
+ class Meta(ComponentModel.Meta):
+ verbose_name = _('virtual disk')
+ verbose_name_plural = _('virtual disks')
+
+ def get_absolute_url(self):
+ return reverse('virtualization:virtualdisk', args=[self.pk])
diff --git a/netbox/virtualization/search.py b/netbox/virtualization/search.py
index 643a9f6de..c72b3345b 100644
--- a/netbox/virtualization/search.py
+++ b/netbox/virtualization/search.py
@@ -10,6 +10,7 @@ class ClusterIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
+ display_attrs = ('type', 'group', 'status', 'tenant', 'site', 'description')
@register_search
@@ -20,6 +21,7 @@ class ClusterGroupIndex(SearchIndex):
('slug', 110),
('description', 500),
)
+ display_attrs = ('description',)
@register_search
@@ -30,6 +32,7 @@ class ClusterTypeIndex(SearchIndex):
('slug', 110),
('description', 500),
)
+ display_attrs = ('description',)
@register_search
@@ -40,6 +43,7 @@ class VirtualMachineIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
+ display_attrs = ('site', 'cluster', 'device', 'tenant', 'platform', 'status', 'role', 'description')
@register_search
@@ -51,3 +55,14 @@ class VMInterfaceIndex(SearchIndex):
('description', 500),
('mtu', 2000),
)
+ display_attrs = ('virtual_machine', 'mac_address', 'description')
+
+
+@register_search
+class VirtualDiskIndex(SearchIndex):
+ model = models.VirtualDisk
+ fields = (
+ ('name', 100),
+ ('description', 500),
+ )
+ display_attrs = ('virtual_machine', 'size', 'description')
diff --git a/netbox/virtualization/signals.py b/netbox/virtualization/signals.py
new file mode 100644
index 000000000..06f172179
--- /dev/null
+++ b/netbox/virtualization/signals.py
@@ -0,0 +1,16 @@
+from django.db.models import Sum
+from django.db.models.signals import post_delete, post_save
+from django.dispatch import receiver
+
+from .models import VirtualDisk, VirtualMachine
+
+
+@receiver((post_delete, post_save), sender=VirtualDisk)
+def update_virtualmachine_disk(instance, **kwargs):
+ """
+ When a VirtualDisk has been modified, update the aggregate disk_size value of its VM.
+ """
+ vm = instance.virtual_machine
+ VirtualMachine.objects.filter(pk=vm.pk).update(
+ disk=vm.virtualdisks.aggregate(Sum('size'))['size__sum']
+ )
diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py
index f8473df1e..632e6878a 100644
--- a/netbox/virtualization/tables/virtualmachines.py
+++ b/netbox/virtualization/tables/virtualmachines.py
@@ -4,10 +4,12 @@ from django.utils.translation import gettext_lazy as _
from dcim.tables.devices import BaseInterfaceTable
from netbox.tables import NetBoxTable, columns
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
-from virtualization.models import VirtualMachine, VMInterface
+from virtualization.models import VirtualDisk, VirtualMachine, VMInterface
__all__ = (
+ 'VirtualDiskTable',
'VirtualMachineTable',
+ 'VirtualMachineVirtualDiskTable',
'VirtualMachineVMInterfaceTable',
'VMInterfaceTable',
)
@@ -22,8 +24,8 @@ VMINTERFACE_BUTTONS = """
{% if perms.ipam.add_ipaddress %}
IP Address
{% endif %}
- {% if perms.ipam.add_l2vpntermination %}
-
L2VPN Termination
+ {% if perms.vpn.add_l2vpntermination %}
+
L2VPN Termination
{% endif %}
{% if perms.ipam.add_fhrpgroupassignment %}
Assign FHRP Group
@@ -84,6 +86,9 @@ class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable)
interface_count = tables.Column(
verbose_name=_('Interfaces')
)
+ virtual_disk_count = tables.Column(
+ verbose_name=_('Virtual Disks')
+ )
config_template = tables.Column(
verbose_name=_('Config Template'),
linkify=True
@@ -126,7 +131,8 @@ class VMInterfaceTable(BaseInterfaceTable):
model = VMInterface
fields = (
'pk', 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags',
- 'vrf', 'l2vpn', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated',
+ 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created',
+ 'last_updated',
)
default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description')
@@ -149,9 +155,45 @@ class VirtualMachineVMInterfaceTable(VMInterfaceTable):
model = VMInterface
fields = (
'pk', 'id', 'name', 'enabled', 'parent', 'bridge', 'mac_address', 'mtu', 'mode', 'description', 'tags',
- 'vrf', 'l2vpn', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'actions',
+ 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'actions',
)
default_columns = ('pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'ip_addresses')
row_attrs = {
'data-name': lambda record: record.name,
}
+
+
+class VirtualDiskTable(NetBoxTable):
+ virtual_machine = tables.Column(
+ verbose_name=_('Virtual Machine'),
+ linkify=True
+ )
+ name = tables.Column(
+ verbose_name=_('Name'),
+ linkify=True
+ )
+ tags = columns.TagColumn(
+ url_name='virtualization:virtualdisk_list'
+ )
+
+ class Meta(NetBoxTable.Meta):
+ model = VirtualDisk
+ fields = (
+ 'pk', 'id', 'virtual_machine', 'name', 'size', 'description', 'tags',
+ )
+ default_columns = ('pk', 'name', 'virtual_machine', 'size', 'description')
+ row_attrs = {
+ 'data-name': lambda record: record.name,
+ }
+
+
+class VirtualMachineVirtualDiskTable(VirtualDiskTable):
+ actions = columns.ActionsColumn(
+ actions=('edit', 'delete'),
+ )
+
+ class Meta(VirtualDiskTable.Meta):
+ fields = (
+ 'pk', 'id', 'name', 'size', 'description', 'tags', 'actions',
+ )
+ default_columns = ('pk', 'name', 'size', 'description')
diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py
index b2ae68860..819ce54e4 100644
--- a/netbox/virtualization/tests/test_api.py
+++ b/netbox/virtualization/tests/test_api.py
@@ -3,10 +3,11 @@ from rest_framework import status
from dcim.choices import InterfaceModeChoices
from dcim.models import Site
+from extras.models import ConfigTemplate
from ipam.models import VLAN, VRF
-from utilities.testing import APITestCase, APIViewTestCases, create_test_device
+from utilities.testing import APITestCase, APIViewTestCases, create_test_device, create_test_virtualmachine
from virtualization.choices import *
-from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
+from virtualization.models import *
class AppTest(APITestCase):
@@ -228,6 +229,22 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase):
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+ def test_render_config(self):
+ configtemplate = ConfigTemplate.objects.create(
+ name='Config Template 1',
+ template_code='Config for virtual machine {{ virtualmachine.name }}'
+ )
+
+ vm = VirtualMachine.objects.first()
+ vm.config_template = configtemplate
+ vm.save()
+
+ self.add_permissions('virtualization.add_virtualmachine')
+ url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': vm.pk}) + 'render-config/'
+ response = self.client.post(url, {}, format='json', **self.header)
+ self.assertHttpStatus(response, status.HTTP_200_OK)
+ self.assertEqual(response.data['content'], f'Config for virtual machine {vm.name}')
+
class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
model = VMInterface
@@ -239,10 +256,7 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
@classmethod
def setUpTestData(cls):
-
- clustertype = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1')
- cluster = Cluster.objects.create(name='Test Cluster 1', type=clustertype)
- virtualmachine = VirtualMachine.objects.create(cluster=cluster, name='Test VM 1')
+ virtualmachine = create_test_virtualmachine('Virtual Machine 1')
interfaces = (
VMInterface(virtual_machine=virtualmachine, name='Interface 1'),
@@ -293,3 +307,67 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
'vrf': vrfs[2].pk,
},
]
+
+ def test_bulk_delete_child_interfaces(self):
+ interface1 = VMInterface.objects.get(name='Interface 1')
+ virtual_machine = interface1.virtual_machine
+ self.add_permissions('virtualization.delete_vminterface')
+
+ # Create a child interface
+ child = VMInterface.objects.create(
+ virtual_machine=virtual_machine,
+ name='Interface 1A',
+ parent=interface1
+ )
+ self.assertEqual(virtual_machine.interfaces.count(), 4)
+
+ # Attempt to delete only the parent interface
+ url = self._get_detail_url(interface1)
+ self.client.delete(url, **self.header)
+ self.assertEqual(virtual_machine.interfaces.count(), 4) # Parent was not deleted
+
+ # Attempt to bulk delete parent & child together
+ data = [
+ {"id": interface1.pk},
+ {"id": child.pk},
+ ]
+ self.client.delete(self._get_list_url(), data, format='json', **self.header)
+ self.assertEqual(virtual_machine.interfaces.count(), 2) # Child & parent were both deleted
+
+
+class VirtualDiskTest(APIViewTestCases.APIViewTestCase):
+ model = VirtualDisk
+ brief_fields = ['display', 'id', 'name', 'size', 'url', 'virtual_machine']
+ bulk_update_data = {
+ 'size': 888,
+ }
+ graphql_base_name = 'virtual_disk'
+
+ @classmethod
+ def setUpTestData(cls):
+ virtualmachine = create_test_virtualmachine('Virtual Machine 1')
+
+ disks = (
+ VirtualDisk(virtual_machine=virtualmachine, name='Disk 1', size=10),
+ VirtualDisk(virtual_machine=virtualmachine, name='Disk 2', size=20),
+ VirtualDisk(virtual_machine=virtualmachine, name='Disk 3', size=30),
+ )
+ VirtualDisk.objects.bulk_create(disks)
+
+ cls.create_data = [
+ {
+ 'virtual_machine': virtualmachine.pk,
+ 'name': 'Disk 4',
+ 'size': 10,
+ },
+ {
+ 'virtual_machine': virtualmachine.pk,
+ 'name': 'Disk 5',
+ 'size': 20,
+ },
+ {
+ 'virtual_machine': virtualmachine.pk,
+ 'name': 'Disk 6',
+ 'size': 30,
+ },
+ ]
diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py
index d474af21a..5c020e1b2 100644
--- a/netbox/virtualization/tests/test_filtersets.py
+++ b/netbox/virtualization/tests/test_filtersets.py
@@ -6,7 +6,7 @@ from tenancy.models import Tenant, TenantGroup
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device
from virtualization.choices import *
from virtualization.filtersets import *
-from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
+from virtualization.models import *
class ClusterTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
@@ -17,12 +17,16 @@ class ClusterTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
def setUpTestData(cls):
cluster_types = (
- ClusterType(name='Cluster Type 1', slug='cluster-type-1', description='A'),
- ClusterType(name='Cluster Type 2', slug='cluster-type-2', description='B'),
- ClusterType(name='Cluster Type 3', slug='cluster-type-3', description='C'),
+ ClusterType(name='Cluster Type 1', slug='cluster-type-1', description='foobar1'),
+ ClusterType(name='Cluster Type 2', slug='cluster-type-2', description='foobar2'),
+ ClusterType(name='Cluster Type 3', slug='cluster-type-3', description='foobar3'),
)
ClusterType.objects.bulk_create(cluster_types)
+ def test_q(self):
+ params = {'q': 'foobar1'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
def test_name(self):
params = {'name': ['Cluster Type 1', 'Cluster Type 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -32,7 +36,7 @@ class ClusterTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
- params = {'description': ['A', 'B']}
+ params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -44,12 +48,16 @@ class ClusterGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
def setUpTestData(cls):
cluster_groups = (
- ClusterGroup(name='Cluster Group 1', slug='cluster-group-1', description='A'),
- ClusterGroup(name='Cluster Group 2', slug='cluster-group-2', description='B'),
- ClusterGroup(name='Cluster Group 3', slug='cluster-group-3', description='C'),
+ ClusterGroup(name='Cluster Group 1', slug='cluster-group-1', description='foobar1'),
+ ClusterGroup(name='Cluster Group 2', slug='cluster-group-2', description='foobar2'),
+ ClusterGroup(name='Cluster Group 3', slug='cluster-group-3', description='foobar3'),
)
ClusterGroup.objects.bulk_create(cluster_groups)
+ def test_q(self):
+ params = {'q': 'foobar1'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
def test_name(self):
params = {'name': ['Cluster Group 1', 'Cluster Group 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -59,7 +67,7 @@ class ClusterGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
- params = {'description': ['A', 'B']}
+ params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -123,16 +131,48 @@ class ClusterTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants)
clusters = (
- Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED, site=sites[0], tenant=tenants[0]),
- Cluster(name='Cluster 2', type=cluster_types[1], group=cluster_groups[1], status=ClusterStatusChoices.STATUS_STAGING, site=sites[1], tenant=tenants[1]),
- Cluster(name='Cluster 3', type=cluster_types[2], group=cluster_groups[2], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[2], tenant=tenants[2]),
+ Cluster(
+ name='Cluster 1',
+ type=cluster_types[0],
+ group=cluster_groups[0],
+ status=ClusterStatusChoices.STATUS_PLANNED,
+ site=sites[0],
+ tenant=tenants[0],
+ description='foobar1'
+ ),
+ Cluster(
+ name='Cluster 2',
+ type=cluster_types[1],
+ group=cluster_groups[1],
+ status=ClusterStatusChoices.STATUS_STAGING,
+ site=sites[1],
+ tenant=tenants[1],
+ description='foobar2'
+ ),
+ Cluster(
+ name='Cluster 3',
+ type=cluster_types[2],
+ group=cluster_groups[2],
+ status=ClusterStatusChoices.STATUS_ACTIVE,
+ site=sites[2],
+ tenant=tenants[2],
+ description='foobar3'
+ ),
)
Cluster.objects.bulk_create(clusters)
+ def test_q(self):
+ params = {'q': 'foobar1'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
def test_name(self):
params = {'name': ['Cluster 1', 'Cluster 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ def test_description(self):
+ params = {'description': ['foobar1', 'foobar2']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
def test_region(self):
regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]}
@@ -274,9 +314,49 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants)
vms = (
- VirtualMachine(name='Virtual Machine 1', site=sites[0], cluster=clusters[0], device=devices[0], platform=platforms[0], role=roles[0], tenant=tenants[0], status=VirtualMachineStatusChoices.STATUS_ACTIVE, vcpus=1, memory=1, disk=1, local_context_data={"foo": 123}),
- VirtualMachine(name='Virtual Machine 2', site=sites[1], cluster=clusters[1], device=devices[1], platform=platforms[1], role=roles[1], tenant=tenants[1], status=VirtualMachineStatusChoices.STATUS_STAGED, vcpus=2, memory=2, disk=2),
- VirtualMachine(name='Virtual Machine 3', site=sites[2], cluster=clusters[2], device=devices[2], platform=platforms[2], role=roles[2], tenant=tenants[2], status=VirtualMachineStatusChoices.STATUS_OFFLINE, vcpus=3, memory=3, disk=3),
+ VirtualMachine(
+ name='Virtual Machine 1',
+ site=sites[0],
+ cluster=clusters[0],
+ device=devices[0],
+ platform=platforms[0],
+ role=roles[0],
+ tenant=tenants[0],
+ status=VirtualMachineStatusChoices.STATUS_ACTIVE,
+ vcpus=1,
+ memory=1,
+ disk=1,
+ description='foobar1',
+ local_context_data={"foo": 123}
+ ),
+ VirtualMachine(
+ name='Virtual Machine 2',
+ site=sites[1],
+ cluster=clusters[1],
+ device=devices[1],
+ platform=platforms[1],
+ role=roles[1],
+ tenant=tenants[1],
+ status=VirtualMachineStatusChoices.STATUS_STAGED,
+ vcpus=2,
+ memory=2,
+ disk=2,
+ description='foobar2'
+ ),
+ VirtualMachine(
+ name='Virtual Machine 3',
+ site=sites[2],
+ cluster=clusters[2],
+ device=devices[2],
+ platform=platforms[2],
+ role=roles[2],
+ tenant=tenants[2],
+ status=VirtualMachineStatusChoices.STATUS_OFFLINE,
+ vcpus=3,
+ memory=3,
+ disk=3,
+ description='foobar3'
+ ),
)
VirtualMachine.objects.bulk_create(vms)
@@ -291,10 +371,18 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
ipaddresses = (
IPAddress(address='192.0.2.1/24', assigned_object=interfaces[0]),
IPAddress(address='192.0.2.2/24', assigned_object=interfaces[1]),
+ IPAddress(address='192.0.2.3/24', assigned_object=None),
+ IPAddress(address='2001:db8::1/64', assigned_object=interfaces[0]),
+ IPAddress(address='2001:db8::2/64', assigned_object=interfaces[1]),
+ IPAddress(address='2001:db8::3/64', assigned_object=None),
)
IPAddress.objects.bulk_create(ipaddresses)
- VirtualMachine.objects.filter(pk=vms[0].pk).update(primary_ip4=ipaddresses[0])
- VirtualMachine.objects.filter(pk=vms[1].pk).update(primary_ip4=ipaddresses[1])
+ VirtualMachine.objects.filter(pk=vms[0].pk).update(primary_ip4=ipaddresses[0], primary_ip6=ipaddresses[3])
+ VirtualMachine.objects.filter(pk=vms[1].pk).update(primary_ip4=ipaddresses[1], primary_ip6=ipaddresses[4])
+
+ def test_q(self):
+ params = {'q': 'foobar1'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Virtual Machine 1', 'Virtual Machine 2']}
@@ -303,6 +391,10 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'name': ['VIRTUAL MACHINE 1', 'VIRTUAL MACHINE 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ def test_description(self):
+ params = {'description': ['foobar1', 'foobar2']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
def test_vcpus(self):
params = {'vcpus': [1, 2]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -412,6 +504,20 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ def test_primary_ip4(self):
+ addresses = IPAddress.objects.filter(address__family=4)
+ params = {'primary_ip4_id': [addresses[0].pk, addresses[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'primary_ip4_id': [addresses[2].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
+
+ def test_primary_ip6(self):
+ addresses = IPAddress.objects.filter(address__family=6)
+ params = {'primary_ip6_id': [addresses[0].pk, addresses[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'primary_ip6_id': [addresses[2].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
+
class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = VMInterface.objects.all()
@@ -449,12 +555,40 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
VirtualMachine.objects.bulk_create(vms)
interfaces = (
- VMInterface(virtual_machine=vms[0], name='Interface 1', enabled=True, mtu=100, mac_address='00-00-00-00-00-01', vrf=vrfs[0], description='foobar1'),
- VMInterface(virtual_machine=vms[1], name='Interface 2', enabled=True, mtu=200, mac_address='00-00-00-00-00-02', vrf=vrfs[1], description='foobar2'),
- VMInterface(virtual_machine=vms[2], name='Interface 3', enabled=False, mtu=300, mac_address='00-00-00-00-00-03', vrf=vrfs[2]),
+ VMInterface(
+ virtual_machine=vms[0],
+ name='Interface 1',
+ enabled=True,
+ mtu=100,
+ mac_address='00-00-00-00-00-01',
+ vrf=vrfs[0],
+ description='foobar1'
+ ),
+ VMInterface(
+ virtual_machine=vms[1],
+ name='Interface 2',
+ enabled=True,
+ mtu=200,
+ mac_address='00-00-00-00-00-02',
+ vrf=vrfs[1],
+ description='foobar2'
+ ),
+ VMInterface(
+ virtual_machine=vms[2],
+ name='Interface 3',
+ enabled=False,
+ mtu=300,
+ mac_address='00-00-00-00-00-03',
+ vrf=vrfs[2],
+ description='foobar3'
+ ),
)
VMInterface.objects.bulk_create(interfaces)
+ def test_q(self):
+ params = {'q': 'foobar1'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
def test_name(self):
params = {'name': ['Interface 1', 'Interface 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -516,3 +650,50 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
+class VirtualDiskTestCase(TestCase, ChangeLoggedFilterSetTests):
+ queryset = VirtualDisk.objects.all()
+ filterset = VirtualDiskFilterSet
+
+ @classmethod
+ def setUpTestData(cls):
+ cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
+ cluster = Cluster.objects.create(name='Cluster 1', type=cluster_type)
+
+ vms = (
+ VirtualMachine(name='Virtual Machine 1', cluster=cluster),
+ VirtualMachine(name='Virtual Machine 2', cluster=cluster),
+ VirtualMachine(name='Virtual Machine 3', cluster=cluster),
+ )
+ VirtualMachine.objects.bulk_create(vms)
+
+ disks = (
+ VirtualDisk(virtual_machine=vms[0], name='Disk 1', size=1, description='foobar1'),
+ VirtualDisk(virtual_machine=vms[1], name='Disk 2', size=2, description='foobar2'),
+ VirtualDisk(virtual_machine=vms[2], name='Disk 3', size=3, description='foobar3'),
+ )
+ VirtualDisk.objects.bulk_create(disks)
+
+ def test_q(self):
+ params = {'q': 'foobar1'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+ def test_virtual_machine(self):
+ vms = VirtualMachine.objects.all()[:2]
+ params = {'virtual_machine_id': [vms[0].pk, vms[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'virtual_machine': [vms[0].name, vms[1].name]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_name(self):
+ params = {'name': ['Disk 1', 'Disk 2']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_size(self):
+ params = {'size': [1, 2]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_description(self):
+ params = {'description': ['foobar1', 'foobar2']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
diff --git a/netbox/virtualization/tests/test_models.py b/netbox/virtualization/tests/test_models.py
index 782b9f07f..c94ff930e 100644
--- a/netbox/virtualization/tests/test_models.py
+++ b/netbox/virtualization/tests/test_models.py
@@ -90,3 +90,28 @@ class VirtualMachineTestCase(TestCase):
# Uniqueness validation for name should ignore case
with self.assertRaises(ValidationError):
vm2.full_clean()
+
+ def test_disk_size(self):
+ vm = VirtualMachine(
+ cluster=Cluster.objects.first(),
+ name='Virtual Machine 1'
+ )
+ vm.save()
+ vm.refresh_from_db()
+ self.assertEqual(vm.disk, None)
+
+ # Create two VirtualDisks
+ VirtualDisk.objects.create(virtual_machine=vm, name='Virtual Disk 1', size=10)
+ VirtualDisk.objects.create(virtual_machine=vm, name='Virtual Disk 2', size=10)
+ vm.refresh_from_db()
+ self.assertEqual(vm.disk, 20)
+
+ # Delete one VirtualDisk
+ VirtualDisk.objects.first().delete()
+ vm.refresh_from_db()
+ self.assertEqual(vm.disk, 10)
+
+ # Attempt to manually overwrite the aggregate disk size
+ vm.disk = 30
+ with self.assertRaises(ValidationError):
+ vm.full_clean()
diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py
index a5d831d7e..ed6bef1e4 100644
--- a/netbox/virtualization/tests/test_views.py
+++ b/netbox/virtualization/tests/test_views.py
@@ -5,9 +5,9 @@ from netaddr import EUI
from dcim.choices import InterfaceModeChoices
from dcim.models import DeviceRole, Platform, Site
from ipam.models import VLAN, VRF
-from utilities.testing import ViewTestCases, create_tags, create_test_device
+from utilities.testing import ViewTestCases, create_tags, create_test_device, create_test_virtualmachine
from virtualization.choices import *
-from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
+from virtualization.models import *
class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
@@ -374,3 +374,83 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
'untagged_vlan': vlans[0].pk,
'tagged_vlans': [v.pk for v in vlans[1:4]],
}
+
+ def test_bulk_delete_child_interfaces(self):
+ interface1 = VMInterface.objects.get(name='Interface 1')
+ virtual_machine = interface1.virtual_machine
+ self.add_permissions('virtualization.delete_vminterface')
+
+ # Create a child interface
+ child = VMInterface.objects.create(
+ virtual_machine=virtual_machine,
+ name='Interface 1A',
+ parent=interface1
+ )
+ self.assertEqual(virtual_machine.interfaces.count(), 4)
+
+ # Attempt to delete only the parent interface
+ data = {
+ 'confirm': True,
+ }
+ self.client.post(self._get_url('delete', interface1), data)
+ self.assertEqual(virtual_machine.interfaces.count(), 4) # Parent was not deleted
+
+ # Attempt to bulk delete parent & child together
+ data = {
+ 'pk': [interface1.pk, child.pk],
+ 'confirm': True,
+ '_confirm': True, # Form button
+ }
+ self.client.post(self._get_url('bulk_delete'), data)
+ self.assertEqual(virtual_machine.interfaces.count(), 2) # Child & parent were both deleted
+
+
+class VirtualDiskTestCase(ViewTestCases.DeviceComponentViewTestCase):
+ model = VirtualDisk
+ validation_excluded_fields = ('name',)
+
+ @classmethod
+ def setUpTestData(cls):
+ virtualmachine = create_test_virtualmachine('Virtual Machine 1')
+
+ disks = VirtualDisk.objects.bulk_create([
+ VirtualDisk(virtual_machine=virtualmachine, name='Virtual Disk 1', size=10),
+ VirtualDisk(virtual_machine=virtualmachine, name='Virtual Disk 2', size=10),
+ VirtualDisk(virtual_machine=virtualmachine, name='Virtual Disk 3', size=10),
+ ])
+
+ tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+ cls.form_data = {
+ 'virtual_machine': virtualmachine.pk,
+ 'name': 'Virtual Disk X',
+ 'size': 20,
+ 'description': 'New description',
+ 'tags': [t.pk for t in tags],
+ }
+
+ cls.bulk_create_data = {
+ 'virtual_machine': virtualmachine.pk,
+ 'name': 'Virtual Disk [4-6]',
+ 'size': 10,
+ 'tags': [t.pk for t in tags],
+ }
+
+ cls.csv_data = (
+ f"virtual_machine,name,size,description",
+ f"Virtual Machine 1,Disk 4,20,Fourth",
+ f"Virtual Machine 1,Disk 5,20,Fifth",
+ f"Virtual Machine 1,Disk 6,20,Sixth",
+ )
+
+ cls.csv_update_data = (
+ f"id,name,size",
+ f"{disks[0].pk},disk1,20",
+ f"{disks[1].pk},disk2,20",
+ f"{disks[2].pk},disk3,20",
+ )
+
+ cls.bulk_edit_data = {
+ 'size': 30,
+ 'description': 'New description',
+ }
diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py
index 9e5d5a670..78f88260a 100644
--- a/netbox/virtualization/urls.py
+++ b/netbox/virtualization/urls.py
@@ -48,4 +48,13 @@ urlpatterns = [
path('interfaces/
/', include(get_model_urls('virtualization', 'vminterface'))),
path('virtual-machines/interfaces/add/', views.VirtualMachineBulkAddInterfaceView.as_view(), name='virtualmachine_bulk_add_vminterface'),
+ # Virtual disks
+ path('disks/', views.VirtualDiskListView.as_view(), name='virtualdisk_list'),
+ path('disks/add/', views.VirtualDiskCreateView.as_view(), name='virtualdisk_add'),
+ path('disks/import/', views.VirtualDiskBulkImportView.as_view(), name='virtualdisk_import'),
+ path('disks/edit/', views.VirtualDiskBulkEditView.as_view(), name='virtualdisk_bulk_edit'),
+ path('disks/rename/', views.VirtualDiskBulkRenameView.as_view(), name='virtualdisk_bulk_rename'),
+ path('disks/delete/', views.VirtualDiskBulkDeleteView.as_view(), name='virtualdisk_bulk_delete'),
+ path('disks//', include(get_model_urls('virtualization', 'virtualdisk'))),
+ path('virtual-machines/disks/add/', views.VirtualMachineBulkAddVirtualDiskView.as_view(), name='virtualmachine_bulk_add_virtualdisk'),
]
diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py
index 9c7748cbd..6019fc227 100644
--- a/netbox/virtualization/views.py
+++ b/netbox/virtualization/views.py
@@ -1,5 +1,4 @@
import traceback
-from collections import defaultdict
from django.contrib import messages
from django.db import transaction
@@ -16,12 +15,14 @@ from dcim.tables import DeviceTable
from extras.views import ObjectConfigContextView
from ipam.models import IPAddress
from ipam.tables import InterfaceVLANTable
+from netbox.constants import DEFAULT_ACTION_PERMISSIONS
from netbox.views import generic
from tenancy.views import ObjectContactsView
+from utilities.query_functions import CollateAsChar
from utilities.utils import count_related
from utilities.views import ViewTab, register_model_view
from . import filtersets, forms, tables
-from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
+from .models import *
#
@@ -199,13 +200,13 @@ class ClusterDevicesView(generic.ObjectChildrenView):
table = DeviceTable
filterset = DeviceFilterSet
template_name = 'virtualization/cluster/devices.html'
- actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_remove_devices')
- action_perms = defaultdict(set, **{
+ actions = {
'add': {'add'},
'import': {'add'},
+ 'export': {'view'},
'bulk_edit': {'change'},
'bulk_remove_devices': {'change'},
- })
+ }
tab = ViewTab(
label=_('Devices'),
badge=lambda obj: obj.devices.count(),
@@ -359,20 +360,16 @@ class VirtualMachineInterfacesView(generic.ObjectChildrenView):
table = tables.VirtualMachineVMInterfaceTable
filterset = filtersets.VMInterfaceFilterSet
template_name = 'virtualization/virtualmachine/interfaces.html'
+ actions = {
+ **DEFAULT_ACTION_PERMISSIONS,
+ 'bulk_rename': {'change'},
+ }
tab = ViewTab(
label=_('Interfaces'),
badge=lambda obj: obj.interface_count,
permission='virtualization.view_vminterface',
weight=500
)
- actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
- action_perms = defaultdict(set, **{
- 'add': {'add'},
- 'import': {'add'},
- 'bulk_edit': {'change'},
- 'bulk_delete': {'delete'},
- 'bulk_rename': {'change'},
- })
def get_children(self, request, parent):
return parent.interfaces.restrict(request.user, 'view').prefetch_related(
@@ -381,13 +378,34 @@ class VirtualMachineInterfacesView(generic.ObjectChildrenView):
)
+@register_model_view(VirtualMachine, 'disks')
+class VirtualMachineVirtualDisksView(generic.ObjectChildrenView):
+ queryset = VirtualMachine.objects.all()
+ child_model = VirtualDisk
+ table = tables.VirtualMachineVirtualDiskTable
+ filterset = filtersets.VirtualDiskFilterSet
+ template_name = 'virtualization/virtualmachine/virtual_disks.html'
+ tab = ViewTab(
+ label=_('Virtual Disks'),
+ badge=lambda obj: obj.virtual_disk_count,
+ permission='virtualization.view_virtual_disk',
+ weight=500
+ )
+ actions = {
+ **DEFAULT_ACTION_PERMISSIONS,
+ 'bulk_rename': {'change'},
+ }
+
+ def get_children(self, request, parent):
+ return parent.virtualdisks.restrict(request.user, 'view').prefetch_related('tags')
+
+
@register_model_view(VirtualMachine, 'configcontext', path='config-context')
class VirtualMachineConfigContextView(ObjectConfigContextView):
queryset = VirtualMachine.objects.annotate_config_context_data()
base_template = 'virtualization/virtualmachine.html'
tab = ViewTab(
label=_('Config Context'),
- permission='extras.view_configcontext',
weight=2000
)
@@ -398,7 +416,6 @@ class VirtualMachineRenderConfigView(generic.ObjectView):
template_name = 'virtualization/virtualmachine/render_config.html'
tab = ViewTab(
label=_('Render Config'),
- permission='extras.view_configtemplate',
weight=2100
)
@@ -555,11 +572,68 @@ class VMInterfaceBulkRenameView(generic.BulkRenameView):
class VMInterfaceBulkDeleteView(generic.BulkDeleteView):
- queryset = VMInterface.objects.all()
+ # Ensure child interfaces are deleted prior to their parents
+ queryset = VMInterface.objects.order_by('virtual_machine', 'parent', CollateAsChar('_name'))
filterset = filtersets.VMInterfaceFilterSet
table = tables.VMInterfaceTable
+#
+# Virtual disks
+#
+
+class VirtualDiskListView(generic.ObjectListView):
+ queryset = VirtualDisk.objects.all()
+ filterset = filtersets.VirtualDiskFilterSet
+ filterset_form = forms.VirtualDiskFilterForm
+ table = tables.VirtualDiskTable
+
+
+@register_model_view(VirtualDisk)
+class VirtualDiskView(generic.ObjectView):
+ queryset = VirtualDisk.objects.all()
+
+
+class VirtualDiskCreateView(generic.ComponentCreateView):
+ queryset = VirtualDisk.objects.all()
+ form = forms.VirtualDiskCreateForm
+ model_form = forms.VirtualDiskForm
+
+
+@register_model_view(VirtualDisk, 'edit')
+class VirtualDiskEditView(generic.ObjectEditView):
+ queryset = VirtualDisk.objects.all()
+ form = forms.VirtualDiskForm
+
+
+@register_model_view(VirtualDisk, 'delete')
+class VirtualDiskDeleteView(generic.ObjectDeleteView):
+ queryset = VirtualDisk.objects.all()
+
+
+class VirtualDiskBulkImportView(generic.BulkImportView):
+ queryset = VirtualDisk.objects.all()
+ model_form = forms.VirtualDiskImportForm
+
+
+class VirtualDiskBulkEditView(generic.BulkEditView):
+ queryset = VirtualDisk.objects.all()
+ filterset = filtersets.VirtualDiskFilterSet
+ table = tables.VirtualDiskTable
+ form = forms.VirtualDiskBulkEditForm
+
+
+class VirtualDiskBulkRenameView(generic.BulkRenameView):
+ queryset = VirtualDisk.objects.all()
+ form = forms.VirtualDiskBulkRenameForm
+
+
+class VirtualDiskBulkDeleteView(generic.BulkDeleteView):
+ queryset = VirtualDisk.objects.all()
+ filterset = filtersets.VirtualDiskFilterSet
+ table = tables.VirtualDiskTable
+
+
#
# Bulk Device component creation
#
@@ -576,3 +650,17 @@ class VirtualMachineBulkAddInterfaceView(generic.BulkComponentCreateView):
def get_required_permission(self):
return f'virtualization.add_vminterface'
+
+
+class VirtualMachineBulkAddVirtualDiskView(generic.BulkComponentCreateView):
+ parent_model = VirtualMachine
+ parent_field = 'virtual_machine'
+ form = forms.VirtualDiskBulkCreateForm
+ queryset = VirtualDisk.objects.all()
+ model_form = forms.VirtualDiskForm
+ filterset = filtersets.VirtualMachineFilterSet
+ table = tables.VirtualMachineTable
+ default_return_url = 'virtualization:virtualmachine_list'
+
+ def get_required_permission(self):
+ return f'virtualization.add_virtualdisk'
diff --git a/netbox/vpn/__init__.py b/netbox/vpn/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/netbox/vpn/admin.py b/netbox/vpn/admin.py
new file mode 100644
index 000000000..8c38f3f3d
--- /dev/null
+++ b/netbox/vpn/admin.py
@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.
diff --git a/netbox/vpn/api/__init__.py b/netbox/vpn/api/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/netbox/vpn/api/nested_serializers.py b/netbox/vpn/api/nested_serializers.py
new file mode 100644
index 000000000..1042b375e
--- /dev/null
+++ b/netbox/vpn/api/nested_serializers.py
@@ -0,0 +1,125 @@
+from drf_spectacular.utils import extend_schema_serializer
+from rest_framework import serializers
+
+from netbox.api.serializers import WritableNestedSerializer
+from vpn import models
+
+__all__ = (
+ 'NestedIKEPolicySerializer',
+ 'NestedIKEProposalSerializer',
+ 'NestedIPSecPolicySerializer',
+ 'NestedIPSecProfileSerializer',
+ 'NestedIPSecProposalSerializer',
+ 'NestedL2VPNSerializer',
+ 'NestedL2VPNTerminationSerializer',
+ 'NestedTunnelGroupSerializer',
+ 'NestedTunnelSerializer',
+ 'NestedTunnelTerminationSerializer',
+)
+
+
+@extend_schema_serializer(
+ exclude_fields=('tunnel_count',),
+)
+class NestedTunnelGroupSerializer(WritableNestedSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='vpn-api:tunnelgroup-detail')
+ tunnel_count = serializers.IntegerField(read_only=True)
+
+ class Meta:
+ model = models.TunnelGroup
+ fields = ['id', 'url', 'display', 'name', 'slug', 'tunnel_count']
+
+
+class NestedTunnelSerializer(WritableNestedSerializer):
+ url = serializers.HyperlinkedIdentityField(
+ view_name='vpn-api:tunnel-detail'
+ )
+
+ class Meta:
+ model = models.Tunnel
+ fields = ('id', 'url', 'display', 'name')
+
+
+class NestedTunnelTerminationSerializer(WritableNestedSerializer):
+ url = serializers.HyperlinkedIdentityField(
+ view_name='vpn-api:tunneltermination-detail'
+ )
+
+ class Meta:
+ model = models.TunnelTermination
+ fields = ('id', 'url', 'display')
+
+
+class NestedIKEProposalSerializer(WritableNestedSerializer):
+ url = serializers.HyperlinkedIdentityField(
+ view_name='vpn-api:ikeproposal-detail'
+ )
+
+ class Meta:
+ model = models.IKEProposal
+ fields = ('id', 'url', 'display', 'name')
+
+
+class NestedIKEPolicySerializer(WritableNestedSerializer):
+ url = serializers.HyperlinkedIdentityField(
+ view_name='vpn-api:ikepolicy-detail'
+ )
+
+ class Meta:
+ model = models.IKEPolicy
+ fields = ('id', 'url', 'display', 'name')
+
+
+class NestedIPSecProposalSerializer(WritableNestedSerializer):
+ url = serializers.HyperlinkedIdentityField(
+ view_name='vpn-api:ipsecproposal-detail'
+ )
+
+ class Meta:
+ model = models.IPSecProposal
+ fields = ('id', 'url', 'display', 'name')
+
+
+class NestedIPSecPolicySerializer(WritableNestedSerializer):
+ url = serializers.HyperlinkedIdentityField(
+ view_name='vpn-api:ipsecpolicy-detail'
+ )
+
+ class Meta:
+ model = models.IPSecPolicy
+ fields = ('id', 'url', 'display', 'name')
+
+
+class NestedIPSecProfileSerializer(WritableNestedSerializer):
+ url = serializers.HyperlinkedIdentityField(
+ view_name='vpn-api:ipsecprofile-detail'
+ )
+
+ class Meta:
+ model = models.IPSecProfile
+ fields = ('id', 'url', 'display', 'name')
+
+
+#
+# L2VPN
+#
+
+class NestedL2VPNSerializer(WritableNestedSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='vpn-api:l2vpn-detail')
+
+ class Meta:
+ model = models.L2VPN
+ fields = [
+ 'id', 'url', 'display', 'identifier', 'name', 'slug', 'type'
+ ]
+
+
+class NestedL2VPNTerminationSerializer(WritableNestedSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='vpn-api:l2vpntermination-detail')
+ l2vpn = NestedL2VPNSerializer()
+
+ class Meta:
+ model = models.L2VPNTermination
+ fields = [
+ 'id', 'url', 'display', 'l2vpn'
+ ]
diff --git a/netbox/vpn/api/serializers.py b/netbox/vpn/api/serializers.py
new file mode 100644
index 000000000..dedcbfbf5
--- /dev/null
+++ b/netbox/vpn/api/serializers.py
@@ -0,0 +1,262 @@
+from django.contrib.contenttypes.models import ContentType
+from drf_spectacular.utils import extend_schema_field
+from rest_framework import serializers
+
+from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedRouteTargetSerializer
+from ipam.models import RouteTarget
+from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
+from netbox.api.serializers import NetBoxModelSerializer
+from netbox.constants import NESTED_SERIALIZER_PREFIX
+from tenancy.api.nested_serializers import NestedTenantSerializer
+from utilities.api import get_serializer_for_model
+from vpn.choices import *
+from vpn.models import *
+from .nested_serializers import *
+
+__all__ = (
+ 'IKEPolicySerializer',
+ 'IKEProposalSerializer',
+ 'IPSecPolicySerializer',
+ 'IPSecProfileSerializer',
+ 'IPSecProposalSerializer',
+ 'L2VPNSerializer',
+ 'L2VPNTerminationSerializer',
+ 'TunnelGroupSerializer',
+ 'TunnelSerializer',
+ 'TunnelTerminationSerializer',
+)
+
+
+class TunnelGroupSerializer(NetBoxModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='vpn-api:tunnelgroup-detail')
+ tunnel_count = serializers.IntegerField(read_only=True)
+
+ class Meta:
+ model = TunnelGroup
+ fields = [
+ 'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
+ 'tunnel_count',
+ ]
+
+
+class TunnelSerializer(NetBoxModelSerializer):
+ url = serializers.HyperlinkedIdentityField(
+ view_name='vpn-api:tunnel-detail'
+ )
+ status = ChoiceField(
+ choices=TunnelStatusChoices
+ )
+ group = NestedTunnelGroupSerializer()
+ encapsulation = ChoiceField(
+ choices=TunnelEncapsulationChoices
+ )
+ ipsec_profile = NestedIPSecProfileSerializer(
+ required=False,
+ allow_null=True
+ )
+ tenant = NestedTenantSerializer(
+ required=False,
+ allow_null=True
+ )
+
+ class Meta:
+ model = Tunnel
+ fields = (
+ 'id', 'url', 'display', 'name', 'status', 'group', 'encapsulation', 'ipsec_profile', 'tenant', 'tunnel_id',
+ 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+ )
+
+
+class TunnelTerminationSerializer(NetBoxModelSerializer):
+ url = serializers.HyperlinkedIdentityField(
+ view_name='vpn-api:tunneltermination-detail'
+ )
+ tunnel = NestedTunnelSerializer()
+ role = ChoiceField(
+ choices=TunnelTerminationRoleChoices
+ )
+ termination_type = ContentTypeField(
+ queryset=ContentType.objects.all()
+ )
+ termination = serializers.SerializerMethodField(
+ read_only=True
+ )
+ outside_ip = NestedIPAddressSerializer(
+ required=False,
+ allow_null=True
+ )
+
+ class Meta:
+ model = TunnelTermination
+ fields = (
+ 'id', 'url', 'display', 'tunnel', 'role', 'termination_type', 'termination_id', 'termination', 'outside_ip',
+ 'tags', 'custom_fields', 'created', 'last_updated',
+ )
+
+ @extend_schema_field(serializers.JSONField(allow_null=True))
+ def get_termination(self, obj):
+ serializer = get_serializer_for_model(obj.termination, prefix=NESTED_SERIALIZER_PREFIX)
+ context = {'request': self.context['request']}
+ return serializer(obj.termination, context=context).data
+
+
+class IKEProposalSerializer(NetBoxModelSerializer):
+ url = serializers.HyperlinkedIdentityField(
+ view_name='vpn-api:ikeproposal-detail'
+ )
+ authentication_method = ChoiceField(
+ choices=AuthenticationMethodChoices
+ )
+ encryption_algorithm = ChoiceField(
+ choices=EncryptionAlgorithmChoices
+ )
+ authentication_algorithm = ChoiceField(
+ choices=AuthenticationAlgorithmChoices
+ )
+ group = ChoiceField(
+ choices=DHGroupChoices
+ )
+
+ class Meta:
+ model = IKEProposal
+ fields = (
+ 'id', 'url', 'display', 'name', 'description', 'authentication_method', 'encryption_algorithm',
+ 'authentication_algorithm', 'group', 'sa_lifetime', 'comments', 'tags', 'custom_fields', 'created',
+ 'last_updated',
+ )
+
+
+class IKEPolicySerializer(NetBoxModelSerializer):
+ url = serializers.HyperlinkedIdentityField(
+ view_name='vpn-api:ikepolicy-detail'
+ )
+ version = ChoiceField(
+ choices=IKEVersionChoices
+ )
+ mode = ChoiceField(
+ choices=IKEModeChoices
+ )
+ proposals = SerializedPKRelatedField(
+ queryset=IKEProposal.objects.all(),
+ serializer=NestedIKEProposalSerializer,
+ required=False,
+ many=True
+ )
+
+ class Meta:
+ model = IKEPolicy
+ fields = (
+ 'id', 'url', 'display', 'name', 'description', 'version', 'mode', 'proposals', 'preshared_key', 'comments',
+ 'tags', 'custom_fields', 'created', 'last_updated',
+ )
+
+
+class IPSecProposalSerializer(NetBoxModelSerializer):
+ url = serializers.HyperlinkedIdentityField(
+ view_name='vpn-api:ipsecproposal-detail'
+ )
+ encryption_algorithm = ChoiceField(
+ choices=EncryptionAlgorithmChoices
+ )
+ authentication_algorithm = ChoiceField(
+ choices=AuthenticationAlgorithmChoices
+ )
+
+ class Meta:
+ model = IPSecProposal
+ fields = (
+ 'id', 'url', 'display', 'name', 'description', 'encryption_algorithm', 'authentication_algorithm',
+ 'sa_lifetime_seconds', 'sa_lifetime_data', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+ )
+
+
+class IPSecPolicySerializer(NetBoxModelSerializer):
+ url = serializers.HyperlinkedIdentityField(
+ view_name='vpn-api:ipsecpolicy-detail'
+ )
+ proposals = SerializedPKRelatedField(
+ queryset=IPSecProposal.objects.all(),
+ serializer=NestedIPSecProposalSerializer,
+ required=False,
+ many=True
+ )
+ pfs_group = ChoiceField(
+ choices=DHGroupChoices,
+ required=False
+ )
+
+ class Meta:
+ model = IPSecPolicy
+ fields = (
+ 'id', 'url', 'display', 'name', 'description', 'proposals', 'pfs_group', 'comments', 'tags',
+ 'custom_fields', 'created', 'last_updated',
+ )
+
+
+class IPSecProfileSerializer(NetBoxModelSerializer):
+ url = serializers.HyperlinkedIdentityField(
+ view_name='vpn-api:ipsecprofile-detail'
+ )
+ mode = ChoiceField(
+ choices=IPSecModeChoices
+ )
+ ike_policy = NestedIKEPolicySerializer()
+ ipsec_policy = NestedIPSecPolicySerializer()
+
+ class Meta:
+ model = IPSecProfile
+ fields = (
+ 'id', 'url', 'display', 'name', 'description', 'mode', 'ike_policy', 'ipsec_policy', 'comments', 'tags',
+ 'custom_fields', 'created', 'last_updated',
+ )
+
+
+#
+# L2VPN
+#
+
+class L2VPNSerializer(NetBoxModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='vpn-api:l2vpn-detail')
+ type = ChoiceField(choices=L2VPNTypeChoices, required=False)
+ import_targets = SerializedPKRelatedField(
+ queryset=RouteTarget.objects.all(),
+ serializer=NestedRouteTargetSerializer,
+ required=False,
+ many=True
+ )
+ export_targets = SerializedPKRelatedField(
+ queryset=RouteTarget.objects.all(),
+ serializer=NestedRouteTargetSerializer,
+ required=False,
+ many=True
+ )
+ tenant = NestedTenantSerializer(required=False, allow_null=True)
+
+ class Meta:
+ model = L2VPN
+ fields = [
+ 'id', 'url', 'display', 'identifier', 'name', 'slug', 'type', 'import_targets', 'export_targets',
+ 'description', 'comments', 'tenant', 'tags', 'custom_fields', 'created', 'last_updated'
+ ]
+
+
+class L2VPNTerminationSerializer(NetBoxModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='vpn-api:l2vpntermination-detail')
+ l2vpn = NestedL2VPNSerializer()
+ assigned_object_type = ContentTypeField(
+ queryset=ContentType.objects.all()
+ )
+ assigned_object = serializers.SerializerMethodField(read_only=True)
+
+ class Meta:
+ model = L2VPNTermination
+ fields = [
+ 'id', 'url', 'display', 'l2vpn', 'assigned_object_type', 'assigned_object_id',
+ 'assigned_object', 'tags', 'custom_fields', 'created', 'last_updated'
+ ]
+
+ @extend_schema_field(serializers.JSONField(allow_null=True))
+ def get_assigned_object(self, instance):
+ serializer = get_serializer_for_model(instance.assigned_object, prefix=NESTED_SERIALIZER_PREFIX)
+ context = {'request': self.context['request']}
+ return serializer(instance.assigned_object, context=context).data
diff --git a/netbox/vpn/api/urls.py b/netbox/vpn/api/urls.py
new file mode 100644
index 000000000..5358325f3
--- /dev/null
+++ b/netbox/vpn/api/urls.py
@@ -0,0 +1,18 @@
+from netbox.api.routers import NetBoxRouter
+from . import views
+
+router = NetBoxRouter()
+router.APIRootView = views.VPNRootView
+router.register('ike-policies', views.IKEPolicyViewSet)
+router.register('ike-proposals', views.IKEProposalViewSet)
+router.register('ipsec-policies', views.IPSecPolicyViewSet)
+router.register('ipsec-proposals', views.IPSecProposalViewSet)
+router.register('ipsec-profiles', views.IPSecProfileViewSet)
+router.register('tunnel-groups', views.TunnelGroupViewSet)
+router.register('tunnels', views.TunnelViewSet)
+router.register('tunnel-terminations', views.TunnelTerminationViewSet)
+router.register('l2vpns', views.L2VPNViewSet)
+router.register('l2vpn-terminations', views.L2VPNTerminationViewSet)
+
+app_name = 'vpn-api'
+urlpatterns = router.urls
diff --git a/netbox/vpn/api/views.py b/netbox/vpn/api/views.py
new file mode 100644
index 000000000..58ad2f47d
--- /dev/null
+++ b/netbox/vpn/api/views.py
@@ -0,0 +1,97 @@
+from rest_framework.routers import APIRootView
+
+from netbox.api.viewsets import NetBoxModelViewSet
+from utilities.utils import count_related
+from vpn import filtersets
+from vpn.models import *
+from . import serializers
+
+__all__ = (
+ 'IKEPolicyViewSet',
+ 'IKEProposalViewSet',
+ 'IPSecPolicyViewSet',
+ 'IPSecProfileViewSet',
+ 'IPSecProposalViewSet',
+ 'L2VPNViewSet',
+ 'L2VPNTerminationViewSet',
+ 'TunnelGroupViewSet',
+ 'TunnelTerminationViewSet',
+ 'TunnelViewSet',
+ 'VPNRootView',
+)
+
+
+class VPNRootView(APIRootView):
+ """
+ VPN API root view
+ """
+ def get_view_name(self):
+ return 'VPN'
+
+
+#
+# Viewsets
+#
+
+class TunnelGroupViewSet(NetBoxModelViewSet):
+ queryset = TunnelGroup.objects.annotate(
+ tunnel_count=count_related(Tunnel, 'group')
+ )
+ serializer_class = serializers.TunnelGroupSerializer
+ filterset_class = filtersets.TunnelGroupFilterSet
+
+
+class TunnelViewSet(NetBoxModelViewSet):
+ queryset = Tunnel.objects.prefetch_related('ipsec_profile', 'tenant').annotate(
+ terminations_count=count_related(TunnelTermination, 'tunnel')
+ )
+ serializer_class = serializers.TunnelSerializer
+ filterset_class = filtersets.TunnelFilterSet
+
+
+class TunnelTerminationViewSet(NetBoxModelViewSet):
+ queryset = TunnelTermination.objects.prefetch_related('tunnel')
+ serializer_class = serializers.TunnelTerminationSerializer
+ filterset_class = filtersets.TunnelTerminationFilterSet
+
+
+class IKEProposalViewSet(NetBoxModelViewSet):
+ queryset = IKEProposal.objects.all()
+ serializer_class = serializers.IKEProposalSerializer
+ filterset_class = filtersets.IKEProposalFilterSet
+
+
+class IKEPolicyViewSet(NetBoxModelViewSet):
+ queryset = IKEPolicy.objects.all()
+ serializer_class = serializers.IKEPolicySerializer
+ filterset_class = filtersets.IKEPolicyFilterSet
+
+
+class IPSecProposalViewSet(NetBoxModelViewSet):
+ queryset = IPSecProposal.objects.all()
+ serializer_class = serializers.IPSecProposalSerializer
+ filterset_class = filtersets.IPSecProposalFilterSet
+
+
+class IPSecPolicyViewSet(NetBoxModelViewSet):
+ queryset = IPSecPolicy.objects.all()
+ serializer_class = serializers.IPSecPolicySerializer
+ filterset_class = filtersets.IPSecPolicyFilterSet
+
+
+class IPSecProfileViewSet(NetBoxModelViewSet):
+ queryset = IPSecProfile.objects.all()
+ serializer_class = serializers.IPSecProfileSerializer
+ filterset_class = filtersets.IPSecProfileFilterSet
+
+
+class L2VPNViewSet(NetBoxModelViewSet):
+ queryset = L2VPN.objects.prefetch_related('import_targets', 'export_targets', 'tenant', 'tags')
+ serializer_class = serializers.L2VPNSerializer
+ filterset_class = filtersets.L2VPNFilterSet
+
+
+class L2VPNTerminationViewSet(NetBoxModelViewSet):
+ queryset = L2VPNTermination.objects.prefetch_related('assigned_object')
+ serializer_class = serializers.L2VPNTerminationSerializer
+ filterset_class = filtersets.L2VPNTerminationFilterSet
diff --git a/netbox/vpn/apps.py b/netbox/vpn/apps.py
new file mode 100644
index 000000000..2254befd3
--- /dev/null
+++ b/netbox/vpn/apps.py
@@ -0,0 +1,9 @@
+from django.apps import AppConfig
+
+
+class VPNConfig(AppConfig):
+ name = 'vpn'
+ verbose_name = 'VPN'
+
+ def ready(self):
+ from . import search
diff --git a/netbox/vpn/choices.py b/netbox/vpn/choices.py
new file mode 100644
index 000000000..a272060e9
--- /dev/null
+++ b/netbox/vpn/choices.py
@@ -0,0 +1,254 @@
+from django.utils.translation import gettext_lazy as _
+
+from utilities.choices import ChoiceSet
+
+
+#
+# Tunnels
+#
+
+class TunnelStatusChoices(ChoiceSet):
+ key = 'Tunnel.status'
+
+ STATUS_PLANNED = 'planned'
+ STATUS_ACTIVE = 'active'
+ STATUS_DISABLED = 'disabled'
+
+ CHOICES = [
+ (STATUS_PLANNED, _('Planned'), 'cyan'),
+ (STATUS_ACTIVE, _('Active'), 'green'),
+ (STATUS_DISABLED, _('Disabled'), 'red'),
+ ]
+
+
+class TunnelEncapsulationChoices(ChoiceSet):
+ ENCAP_GRE = 'gre'
+ ENCAP_IP_IP = 'ip-ip'
+ ENCAP_IPSEC_TRANSPORT = 'ipsec-transport'
+ ENCAP_IPSEC_TUNNEL = 'ipsec-tunnel'
+
+ CHOICES = [
+ (ENCAP_IPSEC_TRANSPORT, _('IPsec - Transport')),
+ (ENCAP_IPSEC_TUNNEL, _('IPsec - Tunnel')),
+ (ENCAP_IP_IP, _('IP-in-IP')),
+ (ENCAP_GRE, _('GRE')),
+ ]
+
+
+class TunnelTerminationTypeChoices(ChoiceSet):
+ # For TunnelCreateForm
+ TYPE_DEVICE = 'dcim.device'
+ TYPE_VIRUTALMACHINE = 'virtualization.virtualmachine'
+
+ CHOICES = (
+ (TYPE_DEVICE, _('Device')),
+ (TYPE_VIRUTALMACHINE, _('Virtual Machine')),
+ )
+
+
+class TunnelTerminationRoleChoices(ChoiceSet):
+ ROLE_PEER = 'peer'
+ ROLE_HUB = 'hub'
+ ROLE_SPOKE = 'spoke'
+
+ CHOICES = [
+ (ROLE_PEER, _('Peer'), 'green'),
+ (ROLE_HUB, _('Hub'), 'blue'),
+ (ROLE_SPOKE, _('Spoke'), 'orange'),
+ ]
+
+
+#
+# Crypto
+#
+
+class IKEVersionChoices(ChoiceSet):
+ VERSION_1 = 1
+ VERSION_2 = 2
+
+ CHOICES = (
+ (VERSION_1, 'IKEv1'),
+ (VERSION_2, 'IKEv2'),
+ )
+
+
+class IKEModeChoices(ChoiceSet):
+ AGGRESSIVE = 'aggressive'
+ MAIN = 'main'
+
+ CHOICES = (
+ (AGGRESSIVE, _('Aggressive')),
+ (MAIN, _('Main')),
+ )
+
+
+class AuthenticationMethodChoices(ChoiceSet):
+ PRESHARED_KEYS = 'preshared-keys'
+ CERTIFICATES = 'certificates'
+ RSA_SIGNATURES = 'rsa-signatures'
+ DSA_SIGNATURES = 'dsa-signatures'
+
+ CHOICES = (
+ (PRESHARED_KEYS, _('Pre-shared keys')),
+ (CERTIFICATES, _('Certificates')),
+ (RSA_SIGNATURES, _('RSA signatures')),
+ (DSA_SIGNATURES, _('DSA signatures')),
+ )
+
+
+class IPSecModeChoices(ChoiceSet):
+ ESP = 'esp'
+ AH = 'ah'
+
+ CHOICES = (
+ (ESP, 'ESP'),
+ (AH, 'AH'),
+ )
+
+
+class EncryptionAlgorithmChoices(ChoiceSet):
+ ENCRYPTION_AES128_CBC = 'aes-128-cbc'
+ ENCRYPTION_AES128_GCM = 'aes-128-gcm'
+ ENCRYPTION_AES192_CBC = 'aes-192-cbc'
+ ENCRYPTION_AES192_GCM = 'aes-192-gcm'
+ ENCRYPTION_AES256_CBC = 'aes-256-cbc'
+ ENCRYPTION_AES256_GCM = 'aes-256-gcm'
+ ENCRYPTION_3DES = '3des-cbc'
+ ENCRYPTION_DES = 'des-cbc'
+
+ CHOICES = (
+ (ENCRYPTION_AES128_CBC, '128-bit AES (CBC)'),
+ (ENCRYPTION_AES128_GCM, '128-bit AES (GCM)'),
+ (ENCRYPTION_AES192_CBC, '192-bit AES (CBC)'),
+ (ENCRYPTION_AES192_GCM, '192-bit AES (GCM)'),
+ (ENCRYPTION_AES256_CBC, '256-bit AES (CBC)'),
+ (ENCRYPTION_AES256_GCM, '256-bit AES (GCM)'),
+ (ENCRYPTION_3DES, '3DES'),
+ (ENCRYPTION_3DES, 'DES'),
+ )
+
+
+class AuthenticationAlgorithmChoices(ChoiceSet):
+ AUTH_HMAC_SHA1 = 'hmac-sha1'
+ AUTH_HMAC_SHA256 = 'hmac-sha256'
+ AUTH_HMAC_SHA384 = 'hmac-sha384'
+ AUTH_HMAC_SHA512 = 'hmac-sha512'
+ AUTH_HMAC_MD5 = 'hmac-md5'
+
+ CHOICES = (
+ (AUTH_HMAC_SHA1, 'SHA-1 HMAC'),
+ (AUTH_HMAC_SHA256, 'SHA-256 HMAC'),
+ (AUTH_HMAC_SHA384, 'SHA-384 HMAC'),
+ (AUTH_HMAC_SHA512, 'SHA-512 HMAC'),
+ (AUTH_HMAC_MD5, 'MD5 HMAC'),
+ )
+
+
+class DHGroupChoices(ChoiceSet):
+ # https://www.iana.org/assignments/ikev2-parameters/ikev2-parameters.xhtml#ikev2-parameters-8
+ GROUP_1 = 1 # 768-bit MODP
+ GROUP_2 = 2 # 1024-but MODP
+ # Groups 3-4 reserved
+ GROUP_5 = 5 # 1536-bit MODP
+ # Groups 6-13 unassigned
+ GROUP_14 = 14 # 2048-bit MODP
+ GROUP_15 = 15 # 3072-bit MODP
+ GROUP_16 = 16 # 4096-bit MODP
+ GROUP_17 = 17 # 6144-bit MODP
+ GROUP_18 = 18 # 8192-bit MODP
+ GROUP_19 = 19 # 256-bit random ECP
+ GROUP_20 = 20 # 384-bit random ECP
+ GROUP_21 = 21 # 521-bit random ECP (521 is not a typo)
+ GROUP_22 = 22 # 1024-bit MODP w/160-bit prime
+ GROUP_23 = 23 # 2048-bit MODP w/224-bit prime
+ GROUP_24 = 24 # 2048-bit MODP w/256-bit prime
+ GROUP_25 = 25 # 192-bit ECP
+ GROUP_26 = 26 # 224-bit ECP
+ GROUP_27 = 27 # brainpoolP224r1
+ GROUP_28 = 28 # brainpoolP256r1
+ GROUP_29 = 29 # brainpoolP384r1
+ GROUP_30 = 30 # brainpoolP512r1
+ GROUP_31 = 31 # Curve25519
+ GROUP_32 = 32 # Curve448
+ GROUP_33 = 33 # GOST3410_2012_256
+ GROUP_34 = 34 # GOST3410_2012_512
+
+ CHOICES = (
+ # Strings are formatted in this manner to optimize translations
+ (GROUP_1, _('Group {n}').format(n=1)),
+ (GROUP_2, _('Group {n}').format(n=2)),
+ (GROUP_5, _('Group {n}').format(n=5)),
+ (GROUP_14, _('Group {n}').format(n=14)),
+ (GROUP_16, _('Group {n}').format(n=16)),
+ (GROUP_17, _('Group {n}').format(n=17)),
+ (GROUP_18, _('Group {n}').format(n=18)),
+ (GROUP_19, _('Group {n}').format(n=19)),
+ (GROUP_20, _('Group {n}').format(n=20)),
+ (GROUP_21, _('Group {n}').format(n=21)),
+ (GROUP_22, _('Group {n}').format(n=22)),
+ (GROUP_23, _('Group {n}').format(n=23)),
+ (GROUP_24, _('Group {n}').format(n=24)),
+ (GROUP_25, _('Group {n}').format(n=25)),
+ (GROUP_26, _('Group {n}').format(n=26)),
+ (GROUP_27, _('Group {n}').format(n=27)),
+ (GROUP_28, _('Group {n}').format(n=28)),
+ (GROUP_29, _('Group {n}').format(n=29)),
+ (GROUP_30, _('Group {n}').format(n=30)),
+ (GROUP_31, _('Group {n}').format(n=31)),
+ (GROUP_32, _('Group {n}').format(n=32)),
+ (GROUP_33, _('Group {n}').format(n=33)),
+ (GROUP_34, _('Group {n}').format(n=34)),
+ )
+
+
+#
+# L2VPN
+#
+
+class L2VPNTypeChoices(ChoiceSet):
+ TYPE_VPLS = 'vpls'
+ TYPE_VPWS = 'vpws'
+ TYPE_EPL = 'epl'
+ TYPE_EVPL = 'evpl'
+ TYPE_EPLAN = 'ep-lan'
+ TYPE_EVPLAN = 'evp-lan'
+ TYPE_EPTREE = 'ep-tree'
+ TYPE_EVPTREE = 'evp-tree'
+ TYPE_VXLAN = 'vxlan'
+ TYPE_VXLAN_EVPN = 'vxlan-evpn'
+ TYPE_MPLS_EVPN = 'mpls-evpn'
+ TYPE_PBB_EVPN = 'pbb-evpn'
+
+ CHOICES = (
+ ('VPLS', (
+ (TYPE_VPWS, 'VPWS'),
+ (TYPE_VPLS, 'VPLS'),
+ )),
+ ('VXLAN', (
+ (TYPE_VXLAN, 'VXLAN'),
+ (TYPE_VXLAN_EVPN, 'VXLAN-EVPN'),
+ )),
+ ('L2VPN E-VPN', (
+ (TYPE_MPLS_EVPN, 'MPLS EVPN'),
+ (TYPE_PBB_EVPN, 'PBB EVPN'),
+ )),
+ ('E-Line', (
+ (TYPE_EPL, 'EPL'),
+ (TYPE_EVPL, 'EVPL'),
+ )),
+ ('E-LAN', (
+ (TYPE_EPLAN, _('Ethernet Private LAN')),
+ (TYPE_EVPLAN, _('Ethernet Virtual Private LAN')),
+ )),
+ ('E-Tree', (
+ (TYPE_EPTREE, _('Ethernet Private Tree')),
+ (TYPE_EVPTREE, _('Ethernet Virtual Private Tree')),
+ )),
+ )
+
+ P2P = (
+ TYPE_VPWS,
+ TYPE_EPL,
+ TYPE_EPLAN,
+ TYPE_EPTREE
+ )
diff --git a/netbox/vpn/constants.py b/netbox/vpn/constants.py
new file mode 100644
index 000000000..55e398dcd
--- /dev/null
+++ b/netbox/vpn/constants.py
@@ -0,0 +1,7 @@
+from django.db.models import Q
+
+L2VPN_ASSIGNMENT_MODELS = Q(
+ Q(app_label='dcim', model='interface') |
+ Q(app_label='ipam', model='vlan') |
+ Q(app_label='virtualization', model='vminterface')
+)
diff --git a/netbox/vpn/filtersets.py b/netbox/vpn/filtersets.py
new file mode 100644
index 000000000..0647838a8
--- /dev/null
+++ b/netbox/vpn/filtersets.py
@@ -0,0 +1,437 @@
+import django_filters
+from django.db.models import Q
+from django.utils.translation import gettext as _
+
+from dcim.models import Device, Interface
+from ipam.models import IPAddress, RouteTarget, VLAN
+from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet
+from tenancy.filtersets import TenancyFilterSet
+from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
+from virtualization.models import VirtualMachine, VMInterface
+from .choices import *
+from .models import *
+
+__all__ = (
+ 'IKEPolicyFilterSet',
+ 'IKEProposalFilterSet',
+ 'IPSecPolicyFilterSet',
+ 'IPSecProfileFilterSet',
+ 'IPSecProposalFilterSet',
+ 'L2VPNFilterSet',
+ 'L2VPNTerminationFilterSet',
+ 'TunnelFilterSet',
+ 'TunnelGroupFilterSet',
+ 'TunnelTerminationFilterSet',
+)
+
+
+class TunnelGroupFilterSet(OrganizationalModelFilterSet):
+
+ class Meta:
+ model = TunnelGroup
+ fields = ['id', 'name', 'slug', 'description']
+
+
+class TunnelFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
+ status = django_filters.MultipleChoiceFilter(
+ choices=TunnelStatusChoices
+ )
+ group_id = django_filters.ModelMultipleChoiceFilter(
+ queryset=TunnelGroup.objects.all(),
+ label=_('Tunnel group (ID)'),
+ )
+ group = django_filters.ModelMultipleChoiceFilter(
+ field_name='group__slug',
+ queryset=TunnelGroup.objects.all(),
+ to_field_name='slug',
+ label=_('Tunnel group (slug)'),
+ )
+ encapsulation = django_filters.MultipleChoiceFilter(
+ choices=TunnelEncapsulationChoices
+ )
+ ipsec_profile_id = django_filters.ModelMultipleChoiceFilter(
+ queryset=IPSecProfile.objects.all(),
+ label=_('IPSec profile (ID)'),
+ )
+ ipsec_profile = django_filters.ModelMultipleChoiceFilter(
+ field_name='ipsec_profile__name',
+ queryset=IPSecProfile.objects.all(),
+ to_field_name='name',
+ label=_('IPSec profile (name)'),
+ )
+
+ class Meta:
+ model = Tunnel
+ fields = ['id', 'name', 'tunnel_id', 'description']
+
+ def search(self, queryset, name, value):
+ if not value.strip():
+ return queryset
+ return queryset.filter(
+ Q(name__icontains=value) |
+ Q(description__icontains=value) |
+ Q(comments__icontains=value)
+ )
+
+
+class TunnelTerminationFilterSet(NetBoxModelFilterSet):
+ tunnel_id = django_filters.ModelMultipleChoiceFilter(
+ field_name='tunnel',
+ queryset=Tunnel.objects.all(),
+ label=_('Tunnel (ID)'),
+ )
+ tunnel = django_filters.ModelMultipleChoiceFilter(
+ field_name='tunnel__name',
+ queryset=Tunnel.objects.all(),
+ to_field_name='name',
+ label=_('Tunnel (name)'),
+ )
+ role = django_filters.MultipleChoiceFilter(
+ choices=TunnelTerminationRoleChoices
+ )
+ termination_type = ContentTypeFilter()
+ interface = django_filters.ModelMultipleChoiceFilter(
+ field_name='interface__name',
+ queryset=Interface.objects.all(),
+ to_field_name='name',
+ label=_('Interface (name)'),
+ )
+ interface_id = django_filters.ModelMultipleChoiceFilter(
+ field_name='interface',
+ queryset=Interface.objects.all(),
+ label=_('Interface (ID)'),
+ )
+ vminterface = django_filters.ModelMultipleChoiceFilter(
+ field_name='vminterface__name',
+ queryset=VMInterface.objects.all(),
+ to_field_name='name',
+ label=_('VM interface (name)'),
+ )
+ vminterface_id = django_filters.ModelMultipleChoiceFilter(
+ field_name='vminterface',
+ queryset=VMInterface.objects.all(),
+ label=_('VM interface (ID)'),
+ )
+ outside_ip_id = django_filters.ModelMultipleChoiceFilter(
+ field_name='outside_ip',
+ queryset=IPAddress.objects.all(),
+ label=_('Outside IP (ID)'),
+ )
+
+ class Meta:
+ model = TunnelTermination
+ fields = ['id']
+
+
+class IKEProposalFilterSet(NetBoxModelFilterSet):
+ authentication_method = django_filters.MultipleChoiceFilter(
+ choices=AuthenticationMethodChoices
+ )
+ encryption_algorithm = django_filters.MultipleChoiceFilter(
+ choices=EncryptionAlgorithmChoices
+ )
+ authentication_algorithm = django_filters.MultipleChoiceFilter(
+ choices=AuthenticationAlgorithmChoices
+ )
+ group = django_filters.MultipleChoiceFilter(
+ choices=DHGroupChoices
+ )
+
+ class Meta:
+ model = IKEProposal
+ fields = ['id', 'name', 'sa_lifetime', 'description']
+
+ def search(self, queryset, name, value):
+ if not value.strip():
+ return queryset
+ return queryset.filter(
+ Q(name__icontains=value) |
+ Q(description__icontains=value) |
+ Q(comments__icontains=value)
+ )
+
+
+class IKEPolicyFilterSet(NetBoxModelFilterSet):
+ version = django_filters.MultipleChoiceFilter(
+ choices=IKEVersionChoices
+ )
+ mode = django_filters.MultipleChoiceFilter(
+ choices=IKEModeChoices
+ )
+ proposal_id = MultiValueNumberFilter(
+ field_name='proposals__id'
+ )
+ proposal = MultiValueCharFilter(
+ field_name='proposals__name'
+ )
+
+ class Meta:
+ model = IKEPolicy
+ fields = ['id', 'name', 'preshared_key', 'description']
+
+ def search(self, queryset, name, value):
+ if not value.strip():
+ return queryset
+ return queryset.filter(
+ Q(name__icontains=value) |
+ Q(description__icontains=value) |
+ Q(comments__icontains=value)
+ )
+
+
+class IPSecProposalFilterSet(NetBoxModelFilterSet):
+ encryption_algorithm = django_filters.MultipleChoiceFilter(
+ choices=EncryptionAlgorithmChoices
+ )
+ authentication_algorithm = django_filters.MultipleChoiceFilter(
+ choices=AuthenticationAlgorithmChoices
+ )
+
+ class Meta:
+ model = IPSecProposal
+ fields = ['id', 'name', 'sa_lifetime_seconds', 'sa_lifetime_data', 'description']
+
+ def search(self, queryset, name, value):
+ if not value.strip():
+ return queryset
+ return queryset.filter(
+ Q(name__icontains=value) |
+ Q(description__icontains=value) |
+ Q(comments__icontains=value)
+ )
+
+
+class IPSecPolicyFilterSet(NetBoxModelFilterSet):
+ pfs_group = django_filters.MultipleChoiceFilter(
+ choices=DHGroupChoices
+ )
+ proposal_id = MultiValueNumberFilter(
+ field_name='proposals__id'
+ )
+ proposal = MultiValueCharFilter(
+ field_name='proposals__name'
+ )
+
+ class Meta:
+ model = IPSecPolicy
+ fields = ['id', 'name', 'description']
+
+ def search(self, queryset, name, value):
+ if not value.strip():
+ return queryset
+ return queryset.filter(
+ Q(name__icontains=value) |
+ Q(description__icontains=value) |
+ Q(comments__icontains=value)
+ )
+
+
+class IPSecProfileFilterSet(NetBoxModelFilterSet):
+ mode = django_filters.MultipleChoiceFilter(
+ choices=IPSecModeChoices
+ )
+ ike_policy_id = django_filters.ModelMultipleChoiceFilter(
+ queryset=IKEPolicy.objects.all(),
+ label=_('IKE policy (ID)'),
+ )
+ ike_policy = django_filters.ModelMultipleChoiceFilter(
+ field_name='ike_policy__name',
+ queryset=IKEPolicy.objects.all(),
+ to_field_name='name',
+ label=_('IKE policy (name)'),
+ )
+ ipsec_policy_id = django_filters.ModelMultipleChoiceFilter(
+ queryset=IPSecPolicy.objects.all(),
+ label=_('IPSec policy (ID)'),
+ )
+ ipsec_policy = django_filters.ModelMultipleChoiceFilter(
+ field_name='ipsec_policy__name',
+ queryset=IPSecPolicy.objects.all(),
+ to_field_name='name',
+ label=_('IPSec policy (name)'),
+ )
+
+ class Meta:
+ model = IPSecProfile
+ fields = ['id', 'name', 'description']
+
+ def search(self, queryset, name, value):
+ if not value.strip():
+ return queryset
+ return queryset.filter(
+ Q(name__icontains=value) |
+ Q(description__icontains=value) |
+ Q(comments__icontains=value)
+ )
+
+
+class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
+ type = django_filters.MultipleChoiceFilter(
+ choices=L2VPNTypeChoices,
+ null_value=None
+ )
+ import_target_id = django_filters.ModelMultipleChoiceFilter(
+ field_name='import_targets',
+ queryset=RouteTarget.objects.all(),
+ label=_('Import target'),
+ )
+ import_target = django_filters.ModelMultipleChoiceFilter(
+ field_name='import_targets__name',
+ queryset=RouteTarget.objects.all(),
+ to_field_name='name',
+ label=_('Import target (name)'),
+ )
+ export_target_id = django_filters.ModelMultipleChoiceFilter(
+ field_name='export_targets',
+ queryset=RouteTarget.objects.all(),
+ label=_('Export target'),
+ )
+ export_target = django_filters.ModelMultipleChoiceFilter(
+ field_name='export_targets__name',
+ queryset=RouteTarget.objects.all(),
+ to_field_name='name',
+ label=_('Export target (name)'),
+ )
+
+ class Meta:
+ model = L2VPN
+ fields = ['id', 'identifier', 'name', 'slug', 'type', 'description']
+
+ def search(self, queryset, name, value):
+ if not value.strip():
+ return queryset
+ qs_filter = Q(name__icontains=value) | Q(description__icontains=value)
+ try:
+ qs_filter |= Q(identifier=int(value))
+ except ValueError:
+ pass
+ return queryset.filter(qs_filter)
+
+
+class L2VPNTerminationFilterSet(NetBoxModelFilterSet):
+ l2vpn_id = django_filters.ModelMultipleChoiceFilter(
+ queryset=L2VPN.objects.all(),
+ label=_('L2VPN (ID)'),
+ )
+ l2vpn = django_filters.ModelMultipleChoiceFilter(
+ field_name='l2vpn__slug',
+ queryset=L2VPN.objects.all(),
+ to_field_name='slug',
+ label=_('L2VPN (slug)'),
+ )
+ region = MultiValueCharFilter(
+ method='filter_region',
+ field_name='slug',
+ label=_('Region (slug)'),
+ )
+ region_id = MultiValueNumberFilter(
+ method='filter_region',
+ field_name='pk',
+ label=_('Region (ID)'),
+ )
+ site = MultiValueCharFilter(
+ method='filter_site',
+ field_name='slug',
+ label=_('Site (slug)'),
+ )
+ site_id = MultiValueNumberFilter(
+ method='filter_site',
+ field_name='pk',
+ label=_('Site (ID)'),
+ )
+ device = django_filters.ModelMultipleChoiceFilter(
+ field_name='interface__device__name',
+ queryset=Device.objects.all(),
+ to_field_name='name',
+ label=_('Device (name)'),
+ )
+ device_id = django_filters.ModelMultipleChoiceFilter(
+ field_name='interface__device',
+ queryset=Device.objects.all(),
+ label=_('Device (ID)'),
+ )
+ virtual_machine = django_filters.ModelMultipleChoiceFilter(
+ field_name='vminterface__virtual_machine__name',
+ queryset=VirtualMachine.objects.all(),
+ to_field_name='name',
+ label=_('Virtual machine (name)'),
+ )
+ virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
+ field_name='vminterface__virtual_machine',
+ queryset=VirtualMachine.objects.all(),
+ label=_('Virtual machine (ID)'),
+ )
+ interface = django_filters.ModelMultipleChoiceFilter(
+ field_name='interface__name',
+ queryset=Interface.objects.all(),
+ to_field_name='name',
+ label=_('Interface (name)'),
+ )
+ interface_id = django_filters.ModelMultipleChoiceFilter(
+ field_name='interface',
+ queryset=Interface.objects.all(),
+ label=_('Interface (ID)'),
+ )
+ vminterface = django_filters.ModelMultipleChoiceFilter(
+ field_name='vminterface__name',
+ queryset=VMInterface.objects.all(),
+ to_field_name='name',
+ label=_('VM interface (name)'),
+ )
+ vminterface_id = django_filters.ModelMultipleChoiceFilter(
+ field_name='vminterface',
+ queryset=VMInterface.objects.all(),
+ label=_('VM Interface (ID)'),
+ )
+ vlan = django_filters.ModelMultipleChoiceFilter(
+ field_name='vlan__name',
+ queryset=VLAN.objects.all(),
+ to_field_name='name',
+ label=_('VLAN (name)'),
+ )
+ vlan_vid = django_filters.NumberFilter(
+ field_name='vlan__vid',
+ label=_('VLAN number (1-4094)'),
+ )
+ vlan_id = django_filters.ModelMultipleChoiceFilter(
+ field_name='vlan',
+ queryset=VLAN.objects.all(),
+ label=_('VLAN (ID)'),
+ )
+ assigned_object_type = ContentTypeFilter()
+
+ class Meta:
+ model = L2VPNTermination
+ fields = ('id', 'assigned_object_type_id')
+
+ def search(self, queryset, name, value):
+ if not value.strip():
+ return queryset
+ qs_filter = Q(l2vpn__name__icontains=value)
+ return queryset.filter(qs_filter)
+
+ def filter_assigned_object(self, queryset, name, value):
+ qs = queryset.filter(
+ Q(**{'{}__in'.format(name): value})
+ )
+ return qs
+
+ def filter_site(self, queryset, name, value):
+ qs = queryset.filter(
+ Q(
+ Q(**{'vlan__site__{}__in'.format(name): value}) |
+ Q(**{'interface__device__site__{}__in'.format(name): value}) |
+ Q(**{'vminterface__virtual_machine__site__{}__in'.format(name): value})
+ )
+ )
+ return qs
+
+ def filter_region(self, queryset, name, value):
+ qs = queryset.filter(
+ Q(
+ Q(**{'vlan__site__region__{}__in'.format(name): value}) |
+ Q(**{'interface__device__site__region__{}__in'.format(name): value}) |
+ Q(**{'vminterface__virtual_machine__site__region__{}__in'.format(name): value})
+ )
+ )
+ return qs
diff --git a/netbox/vpn/forms/__init__.py b/netbox/vpn/forms/__init__.py
new file mode 100644
index 000000000..1499f98b2
--- /dev/null
+++ b/netbox/vpn/forms/__init__.py
@@ -0,0 +1,4 @@
+from .bulk_edit import *
+from .bulk_import import *
+from .filtersets import *
+from .model_forms import *
diff --git a/netbox/vpn/forms/bulk_edit.py b/netbox/vpn/forms/bulk_edit.py
new file mode 100644
index 000000000..a976c5659
--- /dev/null
+++ b/netbox/vpn/forms/bulk_edit.py
@@ -0,0 +1,291 @@
+from django import forms
+from django.utils.translation import gettext_lazy as _
+
+from netbox.forms import NetBoxModelBulkEditForm
+from tenancy.models import Tenant
+from utilities.forms import add_blank_choice
+from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
+from vpn.choices import *
+from vpn.models import *
+
+__all__ = (
+ 'IKEPolicyBulkEditForm',
+ 'IKEProposalBulkEditForm',
+ 'IPSecPolicyBulkEditForm',
+ 'IPSecProfileBulkEditForm',
+ 'IPSecProposalBulkEditForm',
+ 'L2VPNBulkEditForm',
+ 'L2VPNTerminationBulkEditForm',
+ 'TunnelBulkEditForm',
+ 'TunnelGroupBulkEditForm',
+ 'TunnelTerminationBulkEditForm',
+)
+
+
+class TunnelGroupBulkEditForm(NetBoxModelBulkEditForm):
+ description = forms.CharField(
+ label=_('Description'),
+ max_length=200,
+ required=False
+ )
+
+ model = TunnelGroup
+ nullable_fields = ('description',)
+
+
+class TunnelBulkEditForm(NetBoxModelBulkEditForm):
+ status = forms.ChoiceField(
+ label=_('Status'),
+ choices=add_blank_choice(TunnelStatusChoices),
+ required=False
+ )
+ group = DynamicModelChoiceField(
+ queryset=TunnelGroup.objects.all(),
+ label=_('Tunnel group'),
+ required=False
+ )
+ encapsulation = forms.ChoiceField(
+ label=_('Encapsulation'),
+ choices=add_blank_choice(TunnelEncapsulationChoices),
+ required=False
+ )
+ ipsec_profile = DynamicModelMultipleChoiceField(
+ queryset=IPSecProfile.objects.all(),
+ label=_('IPSec profile'),
+ required=False
+ )
+ tenant = DynamicModelChoiceField(
+ label=_('Tenant'),
+ queryset=Tenant.objects.all(),
+ required=False
+ )
+ description = forms.CharField(
+ label=_('Description'),
+ max_length=200,
+ required=False
+ )
+ tunnel_id = forms.IntegerField(
+ label=_('Tunnel ID'),
+ required=False
+ )
+ comments = CommentField()
+
+ model = Tunnel
+ fieldsets = (
+ (_('Tunnel'), ('status', 'group', 'encapsulation', 'tunnel_id', 'description')),
+ (_('Security'), ('ipsec_profile',)),
+ (_('Tenancy'), ('tenant',)),
+ )
+ nullable_fields = (
+ 'group', 'ipsec_profile', 'tunnel_id', 'tenant', 'description', 'comments',
+ )
+
+
+class TunnelTerminationBulkEditForm(NetBoxModelBulkEditForm):
+ role = forms.ChoiceField(
+ label=_('Role'),
+ choices=add_blank_choice(TunnelTerminationRoleChoices),
+ required=False
+ )
+
+ model = TunnelTermination
+
+
+class IKEProposalBulkEditForm(NetBoxModelBulkEditForm):
+ authentication_method = forms.ChoiceField(
+ label=_('Authentication method'),
+ choices=add_blank_choice(AuthenticationMethodChoices),
+ required=False
+ )
+ encryption_algorithm = forms.ChoiceField(
+ label=_('Encryption algorithm'),
+ choices=add_blank_choice(EncryptionAlgorithmChoices),
+ required=False
+ )
+ authentication_algorithm = forms.ChoiceField(
+ label=_('Authentication algorithm'),
+ choices=add_blank_choice(AuthenticationAlgorithmChoices),
+ required=False
+ )
+ group = forms.ChoiceField(
+ label=_('Group'),
+ choices=add_blank_choice(DHGroupChoices),
+ required=False
+ )
+ sa_lifetime = forms.IntegerField(
+ label=_('SA lifetime'),
+ required=False
+ )
+ description = forms.CharField(
+ label=_('Description'),
+ max_length=200,
+ required=False
+ )
+ comments = CommentField()
+
+ model = IKEProposal
+ fieldsets = (
+ (None, (
+ 'authentication_method', 'encryption_algorithm', 'authentication_algorithm', 'group', 'sa_lifetime',
+ 'description',
+ )),
+ )
+ nullable_fields = (
+ 'sa_lifetime', 'description', 'comments',
+ )
+
+
+class IKEPolicyBulkEditForm(NetBoxModelBulkEditForm):
+ version = forms.ChoiceField(
+ label=_('Version'),
+ choices=add_blank_choice(IKEVersionChoices),
+ required=False
+ )
+ mode = forms.ChoiceField(
+ label=_('Mode'),
+ choices=add_blank_choice(IKEModeChoices),
+ required=False
+ )
+ preshared_key = forms.CharField(
+ label=_('Pre-shared key'),
+ required=False
+ )
+ description = forms.CharField(
+ label=_('Description'),
+ max_length=200,
+ required=False
+ )
+ comments = CommentField()
+
+ model = IKEPolicy
+ fieldsets = (
+ (None, (
+ 'version', 'mode', 'preshared_key', 'description',
+ )),
+ )
+ nullable_fields = (
+ 'preshared_key', 'description', 'comments',
+ )
+
+
+class IPSecProposalBulkEditForm(NetBoxModelBulkEditForm):
+ encryption_algorithm = forms.ChoiceField(
+ label=_('Encryption algorithm'),
+ choices=add_blank_choice(EncryptionAlgorithmChoices),
+ required=False
+ )
+ authentication_algorithm = forms.ChoiceField(
+ label=_('Authentication algorithm'),
+ choices=add_blank_choice(AuthenticationAlgorithmChoices),
+ required=False
+ )
+ sa_lifetime_seconds = forms.IntegerField(
+ label=_('SA lifetime (seconds)'),
+ required=False
+ )
+ sa_lifetime_data = forms.IntegerField(
+ label=_('SA lifetime (KB)'),
+ required=False
+ )
+ description = forms.CharField(
+ label=_('Description'),
+ max_length=200,
+ required=False
+ )
+ comments = CommentField()
+
+ model = IPSecProposal
+ fieldsets = (
+ (None, (
+ 'encryption_algorithm', 'authentication_algorithm', 'sa_lifetime_seconds', 'sa_lifetime_data',
+ 'description',
+ )),
+ )
+ nullable_fields = (
+ 'sa_lifetime_seconds', 'sa_lifetime_data', 'description', 'comments',
+ )
+
+
+class IPSecPolicyBulkEditForm(NetBoxModelBulkEditForm):
+ pfs_group = forms.ChoiceField(
+ label=_('PFS group'),
+ choices=add_blank_choice(DHGroupChoices),
+ required=False
+ )
+ description = forms.CharField(
+ label=_('Description'),
+ max_length=200,
+ required=False
+ )
+ comments = CommentField()
+
+ model = IPSecPolicy
+ fieldsets = (
+ (None, ('pfs_group', 'description',)),
+ )
+ nullable_fields = (
+ 'pfs_group', 'description', 'comments',
+ )
+
+
+class IPSecProfileBulkEditForm(NetBoxModelBulkEditForm):
+ mode = forms.ChoiceField(
+ label=_('Mode'),
+ choices=add_blank_choice(IPSecModeChoices),
+ required=False
+ )
+ ike_policy = DynamicModelChoiceField(
+ label=_('IKE policy'),
+ queryset=IKEPolicy.objects.all(),
+ required=False
+ )
+ ipsec_policy = DynamicModelChoiceField(
+ label=_('IPSec policy'),
+ queryset=IPSecPolicy.objects.all(),
+ required=False
+ )
+ description = forms.CharField(
+ label=_('Description'),
+ max_length=200,
+ required=False
+ )
+ comments = CommentField()
+
+ model = IPSecProfile
+ fieldsets = (
+ (_('Profile'), (
+ 'mode', 'ike_policy', 'ipsec_policy', 'description',
+ )),
+ )
+ nullable_fields = (
+ 'description', 'comments',
+ )
+
+
+class L2VPNBulkEditForm(NetBoxModelBulkEditForm):
+ type = forms.ChoiceField(
+ label=_('Type'),
+ choices=add_blank_choice(L2VPNTypeChoices),
+ required=False
+ )
+ tenant = DynamicModelChoiceField(
+ label=_('Tenant'),
+ queryset=Tenant.objects.all(),
+ required=False
+ )
+ description = forms.CharField(
+ label=_('Description'),
+ max_length=200,
+ required=False
+ )
+ comments = CommentField()
+
+ model = L2VPN
+ fieldsets = (
+ (None, ('type', 'tenant', 'description')),
+ )
+ nullable_fields = ('tenant', 'description', 'comments')
+
+
+class L2VPNTerminationBulkEditForm(NetBoxModelBulkEditForm):
+ model = L2VPN
diff --git a/netbox/vpn/forms/bulk_import.py b/netbox/vpn/forms/bulk_import.py
new file mode 100644
index 000000000..c5d53eb1d
--- /dev/null
+++ b/netbox/vpn/forms/bulk_import.py
@@ -0,0 +1,337 @@
+from django.core.exceptions import ValidationError
+from django.utils.translation import gettext_lazy as _
+
+from dcim.models import Device, Interface
+from ipam.models import IPAddress, VLAN
+from netbox.forms import NetBoxModelImportForm
+from tenancy.models import Tenant
+from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField, SlugField
+from virtualization.models import VirtualMachine, VMInterface
+from vpn.choices import *
+from vpn.models import *
+
+__all__ = (
+ 'IKEPolicyImportForm',
+ 'IKEProposalImportForm',
+ 'IPSecPolicyImportForm',
+ 'IPSecProfileImportForm',
+ 'IPSecProposalImportForm',
+ 'L2VPNImportForm',
+ 'L2VPNTerminationImportForm',
+ 'TunnelImportForm',
+ 'TunnelGroupImportForm',
+ 'TunnelTerminationImportForm',
+)
+
+
+class TunnelGroupImportForm(NetBoxModelImportForm):
+ slug = SlugField()
+
+ class Meta:
+ model = TunnelGroup
+ fields = ('name', 'slug', 'description', 'tags')
+
+
+class TunnelImportForm(NetBoxModelImportForm):
+ status = CSVChoiceField(
+ label=_('Status'),
+ choices=TunnelStatusChoices,
+ help_text=_('Operational status')
+ )
+ group = CSVModelChoiceField(
+ label=_('Tunnel group'),
+ queryset=TunnelGroup.objects.all(),
+ required=False,
+ to_field_name='name'
+ )
+ encapsulation = CSVChoiceField(
+ label=_('Encapsulation'),
+ choices=TunnelEncapsulationChoices,
+ help_text=_('Tunnel encapsulation')
+ )
+ ipsec_profile = CSVModelChoiceField(
+ label=_('IPSec profile'),
+ queryset=IPSecProfile.objects.all(),
+ required=False,
+ to_field_name='name'
+ )
+ tenant = CSVModelChoiceField(
+ label=_('Tenant'),
+ queryset=Tenant.objects.all(),
+ required=False,
+ to_field_name='name',
+ help_text=_('Assigned tenant')
+ )
+
+ class Meta:
+ model = Tunnel
+ fields = (
+ 'name', 'status', 'group', 'encapsulation', 'ipsec_profile', 'tenant', 'tunnel_id', 'description',
+ 'comments', 'tags',
+ )
+
+
+class TunnelTerminationImportForm(NetBoxModelImportForm):
+ tunnel = CSVModelChoiceField(
+ label=_('Tunnel'),
+ queryset=Tunnel.objects.all(),
+ to_field_name='name'
+ )
+ role = CSVChoiceField(
+ label=_('Role'),
+ choices=TunnelTerminationRoleChoices,
+ help_text=_('Operational role')
+ )
+ device = CSVModelChoiceField(
+ label=_('Device'),
+ queryset=Device.objects.all(),
+ required=False,
+ to_field_name='name',
+ help_text=_('Parent device of assigned interface')
+ )
+ virtual_machine = CSVModelChoiceField(
+ label=_('Virtual machine'),
+ queryset=VirtualMachine.objects.all(),
+ required=False,
+ to_field_name='name',
+ help_text=_('Parent VM of assigned interface')
+ )
+ termination = CSVModelChoiceField(
+ label=_('Termination'),
+ queryset=Interface.objects.none(), # Can also refer to VMInterface
+ required=False,
+ to_field_name='name',
+ help_text=_('Device or virtual machine interface')
+ )
+ outside_ip = CSVModelChoiceField(
+ label=_('Outside IP'),
+ queryset=IPAddress.objects.all(),
+ required=False,
+ to_field_name='name'
+ )
+
+ class Meta:
+ model = TunnelTermination
+ fields = (
+ 'tunnel', 'role', 'outside_ip', 'tags',
+ )
+
+ def __init__(self, data=None, *args, **kwargs):
+ super().__init__(data, *args, **kwargs)
+
+ if data:
+
+ # Limit termination queryset by assigned device/VM
+ if data.get('device'):
+ self.fields['termination'].queryset = Interface.objects.filter(
+ **{f"device__{self.fields['device'].to_field_name}": data['device']}
+ )
+ elif data.get('virtual_machine'):
+ self.fields['termination'].queryset = VMInterface.objects.filter(
+ **{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']}
+ )
+
+ def save(self, *args, **kwargs):
+
+ # Assign termination object
+ if self.cleaned_data.get('termination'):
+ self.instance.termination = self.cleaned_data['termination']
+
+ return super().save(*args, **kwargs)
+
+
+class IKEProposalImportForm(NetBoxModelImportForm):
+ authentication_method = CSVChoiceField(
+ label=_('Authentication method'),
+ choices=AuthenticationMethodChoices
+ )
+ encryption_algorithm = CSVChoiceField(
+ label=_('Encryption algorithm'),
+ choices=EncryptionAlgorithmChoices
+ )
+ authentication_algorithm = CSVChoiceField(
+ label=_('Authentication algorithm'),
+ choices=AuthenticationAlgorithmChoices
+ )
+ group = CSVChoiceField(
+ label=_('Group'),
+ choices=DHGroupChoices
+ )
+
+ class Meta:
+ model = IKEProposal
+ fields = (
+ 'name', 'description', 'authentication_method', 'encryption_algorithm', 'authentication_algorithm',
+ 'group', 'sa_lifetime', 'comments', 'tags',
+ )
+
+
+class IKEPolicyImportForm(NetBoxModelImportForm):
+ version = CSVChoiceField(
+ label=_('Version'),
+ choices=IKEVersionChoices
+ )
+ mode = CSVChoiceField(
+ label=_('Mode'),
+ choices=IKEModeChoices
+ )
+ proposals = CSVModelMultipleChoiceField(
+ queryset=IKEProposal.objects.all(),
+ to_field_name='name',
+ help_text=_('IKE proposal(s)'),
+ )
+
+ class Meta:
+ model = IKEPolicy
+ fields = (
+ 'name', 'description', 'version', 'mode', 'proposals', 'preshared_key', 'comments', 'tags',
+ )
+
+
+class IPSecProposalImportForm(NetBoxModelImportForm):
+ encryption_algorithm = CSVChoiceField(
+ label=_('Encryption algorithm'),
+ choices=EncryptionAlgorithmChoices
+ )
+ authentication_algorithm = CSVChoiceField(
+ label=_('Authentication algorithm'),
+ choices=AuthenticationAlgorithmChoices
+ )
+
+ class Meta:
+ model = IPSecProposal
+ fields = (
+ 'name', 'description', 'encryption_algorithm', 'authentication_algorithm', 'sa_lifetime_seconds',
+ 'sa_lifetime_data', 'comments', 'tags',
+ )
+
+
+class IPSecPolicyImportForm(NetBoxModelImportForm):
+ pfs_group = CSVChoiceField(
+ label=_('Diffie-Hellman group for Perfect Forward Secrecy'),
+ choices=DHGroupChoices
+ )
+ proposals = CSVModelMultipleChoiceField(
+ queryset=IPSecProposal.objects.all(),
+ to_field_name='name',
+ help_text=_('IPSec proposal(s)'),
+ )
+
+ class Meta:
+ model = IPSecPolicy
+ fields = (
+ 'name', 'description', 'proposals', 'pfs_group', 'comments', 'tags',
+ )
+
+
+class IPSecProfileImportForm(NetBoxModelImportForm):
+ mode = CSVChoiceField(
+ label=_('Mode'),
+ choices=IPSecModeChoices,
+ help_text=_('IPSec protocol')
+ )
+ ike_policy = CSVModelChoiceField(
+ label=_('IKE policy'),
+ queryset=IKEPolicy.objects.all(),
+ to_field_name='name'
+ )
+ ipsec_policy = CSVModelChoiceField(
+ label=_('IPSec policy'),
+ queryset=IPSecPolicy.objects.all(),
+ to_field_name='name'
+ )
+
+ class Meta:
+ model = IPSecProfile
+ fields = (
+ 'name', 'mode', 'ike_policy', 'ipsec_policy', 'description', 'comments', 'tags',
+ )
+
+
+class L2VPNImportForm(NetBoxModelImportForm):
+ tenant = CSVModelChoiceField(
+ label=_('Tenant'),
+ queryset=Tenant.objects.all(),
+ required=False,
+ to_field_name='name',
+ )
+ type = CSVChoiceField(
+ label=_('Type'),
+ choices=L2VPNTypeChoices,
+ help_text=_('L2VPN type')
+ )
+
+ class Meta:
+ model = L2VPN
+ fields = ('identifier', 'name', 'slug', 'tenant', 'type', 'description',
+ 'comments', 'tags')
+
+
+class L2VPNTerminationImportForm(NetBoxModelImportForm):
+ l2vpn = CSVModelChoiceField(
+ queryset=L2VPN.objects.all(),
+ required=True,
+ to_field_name='name',
+ label=_('L2VPN'),
+ )
+ device = CSVModelChoiceField(
+ label=_('Device'),
+ queryset=Device.objects.all(),
+ required=False,
+ to_field_name='name',
+ help_text=_('Parent device (for interface)')
+ )
+ virtual_machine = CSVModelChoiceField(
+ label=_('Virtual machine'),
+ queryset=VirtualMachine.objects.all(),
+ required=False,
+ to_field_name='name',
+ help_text=_('Parent virtual machine (for interface)')
+ )
+ interface = CSVModelChoiceField(
+ label=_('Interface'),
+ queryset=Interface.objects.none(), # Can also refer to VMInterface
+ required=False,
+ to_field_name='name',
+ help_text=_('Assigned interface (device or VM)')
+ )
+ vlan = CSVModelChoiceField(
+ label=_('VLAN'),
+ queryset=VLAN.objects.all(),
+ required=False,
+ to_field_name='name',
+ help_text=_('Assigned VLAN')
+ )
+
+ class Meta:
+ model = L2VPNTermination
+ fields = ('l2vpn', 'device', 'virtual_machine', 'interface', 'vlan', 'tags')
+
+ def __init__(self, data=None, *args, **kwargs):
+ super().__init__(data, *args, **kwargs)
+
+ if data:
+
+ # Limit interface queryset by device or VM
+ if data.get('device'):
+ self.fields['interface'].queryset = Interface.objects.filter(
+ **{f"device__{self.fields['device'].to_field_name}": data['device']}
+ )
+ elif data.get('virtual_machine'):
+ self.fields['interface'].queryset = VMInterface.objects.filter(
+ **{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']}
+ )
+
+ def clean(self):
+ super().clean()
+
+ if self.cleaned_data.get('device') and self.cleaned_data.get('virtual_machine'):
+ 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')
diff --git a/netbox/vpn/forms/filtersets.py b/netbox/vpn/forms/filtersets.py
new file mode 100644
index 000000000..a9326c4bc
--- /dev/null
+++ b/netbox/vpn/forms/filtersets.py
@@ -0,0 +1,290 @@
+from django import forms
+from django.contrib.contenttypes.models import ContentType
+from django.utils.translation import gettext as _
+
+from dcim.models import Device, Region, Site
+from ipam.models import RouteTarget, VLAN
+from netbox.forms import NetBoxModelFilterSetForm
+from tenancy.forms import TenancyFilterForm
+from utilities.forms.fields import (
+ ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField,
+)
+from utilities.forms.utils import add_blank_choice
+from virtualization.models import VirtualMachine
+from vpn.choices import *
+from vpn.constants import L2VPN_ASSIGNMENT_MODELS
+from vpn.models import *
+
+__all__ = (
+ 'IKEPolicyFilterForm',
+ 'IKEProposalFilterForm',
+ 'IPSecPolicyFilterForm',
+ 'IPSecProfileFilterForm',
+ 'IPSecProposalFilterForm',
+ 'L2VPNFilterForm',
+ 'L2VPNTerminationFilterForm',
+ 'TunnelFilterForm',
+ 'TunnelGroupFilterForm',
+ 'TunnelTerminationFilterForm',
+)
+
+
+class TunnelGroupFilterForm(NetBoxModelFilterSetForm):
+ model = TunnelGroup
+ tag = TagFilterField(model)
+
+
+class TunnelFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
+ model = Tunnel
+ fieldsets = (
+ (None, ('q', 'filter_id', 'tag')),
+ (_('Tunnel'), ('status', 'encapsulation', 'tunnel_id')),
+ (_('Security'), ('ipsec_profile_id',)),
+ (_('Tenancy'), ('tenant_group_id', 'tenant_id')),
+ )
+ status = forms.MultipleChoiceField(
+ label=_('Status'),
+ choices=TunnelStatusChoices,
+ required=False
+ )
+ group_id = DynamicModelMultipleChoiceField(
+ queryset=TunnelGroup.objects.all(),
+ required=False,
+ label=_('Tunnel group')
+ )
+ encapsulation = forms.MultipleChoiceField(
+ label=_('Encapsulation'),
+ choices=TunnelEncapsulationChoices,
+ required=False
+ )
+ ipsec_profile_id = DynamicModelMultipleChoiceField(
+ queryset=IPSecProfile.objects.all(),
+ required=False,
+ label=_('IPSec profile')
+ )
+ tunnel_id = forms.IntegerField(
+ required=False,
+ label=_('Tunnel ID')
+ )
+ tag = TagFilterField(model)
+
+
+class TunnelTerminationFilterForm(NetBoxModelFilterSetForm):
+ model = TunnelTermination
+ fieldsets = (
+ (None, ('q', 'filter_id', 'tag')),
+ (_('Termination'), ('tunnel_id', 'role')),
+ )
+ tunnel_id = DynamicModelMultipleChoiceField(
+ queryset=Tunnel.objects.all(),
+ required=False,
+ label=_('Tunnel')
+ )
+ role = forms.MultipleChoiceField(
+ label=_('Role'),
+ choices=TunnelTerminationRoleChoices,
+ required=False
+ )
+ tag = TagFilterField(model)
+
+
+class IKEProposalFilterForm(NetBoxModelFilterSetForm):
+ model = IKEProposal
+ fieldsets = (
+ (None, ('q', 'filter_id', 'tag')),
+ (_('Parameters'), ('authentication_method', 'encryption_algorithm', 'authentication_algorithm', 'group')),
+ )
+ authentication_method = forms.MultipleChoiceField(
+ label=_('Authentication method'),
+ choices=AuthenticationMethodChoices,
+ required=False
+ )
+ encryption_algorithm = forms.MultipleChoiceField(
+ label=_('Encryption algorithm'),
+ choices=EncryptionAlgorithmChoices,
+ required=False
+ )
+ authentication_algorithm = forms.MultipleChoiceField(
+ label=_('Authentication algorithm'),
+ choices=AuthenticationAlgorithmChoices,
+ required=False
+ )
+ group = forms.MultipleChoiceField(
+ label=_('Group'),
+ choices=DHGroupChoices,
+ required=False
+ )
+ tag = TagFilterField(model)
+
+
+class IKEPolicyFilterForm(NetBoxModelFilterSetForm):
+ model = IKEPolicy
+ fieldsets = (
+ (None, ('q', 'filter_id', 'tag')),
+ (_('Parameters'), ('version', 'mode', 'proposal_id')),
+ )
+ version = forms.MultipleChoiceField(
+ label=_('IKE version'),
+ choices=IKEVersionChoices,
+ required=False
+ )
+ mode = forms.MultipleChoiceField(
+ label=_('Mode'),
+ choices=IKEModeChoices,
+ required=False
+ )
+ proposal_id = DynamicModelMultipleChoiceField(
+ queryset=IKEProposal.objects.all(),
+ required=False,
+ label=_('Proposal')
+ )
+ tag = TagFilterField(model)
+
+
+class IPSecProposalFilterForm(NetBoxModelFilterSetForm):
+ model = IPSecProposal
+ fieldsets = (
+ (None, ('q', 'filter_id', 'tag')),
+ (_('Parameters'), ('encryption_algorithm', 'authentication_algorithm')),
+ )
+ encryption_algorithm = forms.MultipleChoiceField(
+ label=_('Encryption algorithm'),
+ choices=EncryptionAlgorithmChoices,
+ required=False
+ )
+ authentication_algorithm = forms.MultipleChoiceField(
+ label=_('Authentication algorithm'),
+ choices=AuthenticationAlgorithmChoices,
+ required=False
+ )
+ tag = TagFilterField(model)
+
+
+class IPSecPolicyFilterForm(NetBoxModelFilterSetForm):
+ model = IPSecPolicy
+ fieldsets = (
+ (None, ('q', 'filter_id', 'tag')),
+ (_('Parameters'), ('proposal_id', 'pfs_group')),
+ )
+ proposal_id = DynamicModelMultipleChoiceField(
+ queryset=IKEProposal.objects.all(),
+ required=False,
+ label=_('Proposal')
+ )
+ pfs_group = forms.MultipleChoiceField(
+ label=_('Mode'),
+ choices=DHGroupChoices,
+ required=False
+ )
+ tag = TagFilterField(model)
+
+
+class IPSecProfileFilterForm(NetBoxModelFilterSetForm):
+ model = IPSecProfile
+ fieldsets = (
+ (None, ('q', 'filter_id', 'tag')),
+ (_('Profile'), ('mode', 'ike_policy_id', 'ipsec_policy_id')),
+ )
+ mode = forms.MultipleChoiceField(
+ label=_('Mode'),
+ choices=IPSecModeChoices,
+ required=False
+ )
+ ike_policy_id = DynamicModelMultipleChoiceField(
+ queryset=IKEPolicy.objects.all(),
+ required=False,
+ label=_('IKE policy')
+ )
+ ipsec_policy_id = DynamicModelMultipleChoiceField(
+ queryset=IPSecPolicy.objects.all(),
+ required=False,
+ label=_('IPSec policy')
+ )
+ tag = TagFilterField(model)
+
+
+class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
+ model = L2VPN
+ fieldsets = (
+ (None, ('q', 'filter_id', 'tag')),
+ (_('Attributes'), ('type', 'import_target_id', 'export_target_id')),
+ (_('Tenant'), ('tenant_group_id', 'tenant_id')),
+ )
+ type = forms.ChoiceField(
+ label=_('Type'),
+ choices=add_blank_choice(L2VPNTypeChoices),
+ required=False
+ )
+ import_target_id = DynamicModelMultipleChoiceField(
+ queryset=RouteTarget.objects.all(),
+ required=False,
+ label=_('Import targets')
+ )
+ export_target_id = DynamicModelMultipleChoiceField(
+ queryset=RouteTarget.objects.all(),
+ required=False,
+ label=_('Export targets')
+ )
+ tag = TagFilterField(model)
+
+
+class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm):
+ model = L2VPNTermination
+ fieldsets = (
+ (None, ('filter_id', 'l2vpn_id',)),
+ (_('Assigned Object'), (
+ 'assigned_object_type_id', 'region_id', 'site_id', 'device_id', 'virtual_machine_id', 'vlan_id',
+ )),
+ )
+ l2vpn_id = DynamicModelChoiceField(
+ queryset=L2VPN.objects.all(),
+ required=False,
+ label=_('L2VPN')
+ )
+ assigned_object_type_id = ContentTypeMultipleChoiceField(
+ queryset=ContentType.objects.filter(L2VPN_ASSIGNMENT_MODELS),
+ required=False,
+ label=_('Assigned Object Type'),
+ limit_choices_to=L2VPN_ASSIGNMENT_MODELS
+ )
+ region_id = DynamicModelMultipleChoiceField(
+ queryset=Region.objects.all(),
+ required=False,
+ label=_('Region')
+ )
+ site_id = DynamicModelMultipleChoiceField(
+ queryset=Site.objects.all(),
+ required=False,
+ null_option='None',
+ query_params={
+ 'region_id': '$region_id'
+ },
+ label=_('Site')
+ )
+ device_id = DynamicModelMultipleChoiceField(
+ queryset=Device.objects.all(),
+ required=False,
+ null_option='None',
+ query_params={
+ 'site_id': '$site_id'
+ },
+ label=_('Device')
+ )
+ vlan_id = DynamicModelMultipleChoiceField(
+ queryset=VLAN.objects.all(),
+ required=False,
+ null_option='None',
+ query_params={
+ 'site_id': '$site_id'
+ },
+ label=_('VLAN')
+ )
+ virtual_machine_id = DynamicModelMultipleChoiceField(
+ queryset=VirtualMachine.objects.all(),
+ required=False,
+ null_option='None',
+ query_params={
+ 'site_id': '$site_id'
+ },
+ label=_('Virtual Machine')
+ )
diff --git a/netbox/vpn/forms/model_forms.py b/netbox/vpn/forms/model_forms.py
new file mode 100644
index 000000000..3068bfac2
--- /dev/null
+++ b/netbox/vpn/forms/model_forms.py
@@ -0,0 +1,473 @@
+from django import forms
+from django.core.exceptions import ValidationError
+from django.utils.translation import gettext_lazy as _
+
+from dcim.models import Device, Interface
+from ipam.models import IPAddress, RouteTarget, VLAN
+from netbox.forms import NetBoxModelForm
+from tenancy.forms import TenancyForm
+from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField
+from utilities.forms.utils import add_blank_choice
+from utilities.forms.widgets import HTMXSelect
+from virtualization.models import VirtualMachine, VMInterface
+from vpn.choices import *
+from vpn.models import *
+
+__all__ = (
+ 'IKEPolicyForm',
+ 'IKEProposalForm',
+ 'IPSecPolicyForm',
+ 'IPSecProfileForm',
+ 'IPSecProposalForm',
+ 'L2VPNForm',
+ 'L2VPNTerminationForm',
+ 'TunnelCreateForm',
+ 'TunnelForm',
+ 'TunnelGroupForm',
+ 'TunnelTerminationForm',
+)
+
+
+class TunnelGroupForm(NetBoxModelForm):
+ slug = SlugField()
+
+ fieldsets = (
+ (_('Tunnel Group'), ('name', 'slug', 'description', 'tags')),
+ )
+
+ class Meta:
+ model = TunnelGroup
+ fields = [
+ 'name', 'slug', 'description', 'tags',
+ ]
+
+
+class TunnelForm(TenancyForm, NetBoxModelForm):
+ group = DynamicModelChoiceField(
+ queryset=TunnelGroup.objects.all(),
+ label=_('Tunnel Group'),
+ required=False
+ )
+ ipsec_profile = DynamicModelChoiceField(
+ queryset=IPSecProfile.objects.all(),
+ label=_('IPSec Profile'),
+ required=False
+ )
+ comments = CommentField()
+
+ fieldsets = (
+ (_('Tunnel'), ('name', 'status', 'group', 'encapsulation', 'description', 'tunnel_id', 'tags')),
+ (_('Security'), ('ipsec_profile',)),
+ (_('Tenancy'), ('tenant_group', 'tenant')),
+ )
+
+ class Meta:
+ model = Tunnel
+ fields = [
+ 'name', 'status', 'group', 'encapsulation', 'description', 'tunnel_id', 'ipsec_profile', 'tenant_group',
+ 'tenant', 'comments', 'tags',
+ ]
+
+
+class TunnelCreateForm(TunnelForm):
+ # First termination
+ termination1_role = forms.ChoiceField(
+ choices=add_blank_choice(TunnelTerminationRoleChoices),
+ required=False,
+ label=_('Role')
+ )
+ termination1_type = forms.ChoiceField(
+ choices=TunnelTerminationTypeChoices,
+ required=False,
+ widget=HTMXSelect(),
+ label=_('Type')
+ )
+ termination1_parent = DynamicModelChoiceField(
+ queryset=Device.objects.all(),
+ required=False,
+ selector=True,
+ label=_('Device')
+ )
+ termination1_termination = DynamicModelChoiceField(
+ queryset=Interface.objects.all(),
+ required=False,
+ label=_('Interface'),
+ query_params={
+ 'device_id': '$termination1_parent',
+ }
+ )
+ termination1_outside_ip = DynamicModelChoiceField(
+ queryset=IPAddress.objects.all(),
+ label=_('Outside IP'),
+ required=False,
+ query_params={
+ 'device_id': '$termination1_parent',
+ }
+ )
+
+ # Second termination
+ termination2_role = forms.ChoiceField(
+ choices=add_blank_choice(TunnelTerminationRoleChoices),
+ required=False,
+ label=_('Role')
+ )
+ termination2_type = forms.ChoiceField(
+ choices=TunnelTerminationTypeChoices,
+ required=False,
+ widget=HTMXSelect(),
+ label=_('Type')
+ )
+ termination2_parent = DynamicModelChoiceField(
+ queryset=Device.objects.all(),
+ required=False,
+ selector=True,
+ label=_('Device')
+ )
+ termination2_termination = DynamicModelChoiceField(
+ queryset=Interface.objects.all(),
+ required=False,
+ label=_('Interface'),
+ query_params={
+ 'device_id': '$termination2_parent',
+ }
+ )
+ termination2_outside_ip = DynamicModelChoiceField(
+ queryset=IPAddress.objects.all(),
+ required=False,
+ label=_('Outside IP'),
+ query_params={
+ 'device_id': '$termination2_parent',
+ }
+ )
+
+ fieldsets = (
+ (_('Tunnel'), ('name', 'status', 'encapsulation', 'description', 'tunnel_id', 'tags')),
+ (_('Security'), ('ipsec_profile',)),
+ (_('Tenancy'), ('tenant_group', 'tenant')),
+ (_('First Termination'), (
+ 'termination1_role', 'termination1_type', 'termination1_parent', 'termination1_termination',
+ 'termination1_outside_ip',
+ )),
+ (_('Second Termination'), (
+ 'termination2_role', 'termination2_type', 'termination2_parent', 'termination2_termination',
+ 'termination2_outside_ip',
+ )),
+ )
+
+ def __init__(self, *args, initial=None, **kwargs):
+ super().__init__(*args, initial=initial, **kwargs)
+
+ if initial and initial.get('termination1_type') == TunnelTerminationTypeChoices.TYPE_VIRUTALMACHINE:
+ self.fields['termination1_parent'].label = _('Virtual Machine')
+ self.fields['termination1_parent'].queryset = VirtualMachine.objects.all()
+ self.fields['termination1_termination'].queryset = VMInterface.objects.all()
+ self.fields['termination1_termination'].widget.add_query_params({
+ 'virtual_machine_id': '$termination1_parent',
+ })
+ self.fields['termination1_outside_ip'].widget.add_query_params({
+ 'virtual_machine_id': '$termination1_parent',
+ })
+
+ if initial and initial.get('termination2_type') == TunnelTerminationTypeChoices.TYPE_VIRUTALMACHINE:
+ self.fields['termination2_parent'].label = _('Virtual Machine')
+ self.fields['termination2_parent'].queryset = VirtualMachine.objects.all()
+ self.fields['termination2_termination'].queryset = VMInterface.objects.all()
+ self.fields['termination2_termination'].widget.add_query_params({
+ 'virtual_machine_id': '$termination2_parent',
+ })
+ self.fields['termination2_outside_ip'].widget.add_query_params({
+ 'virtual_machine_id': '$termination2_parent',
+ })
+
+ def clean(self):
+ super().clean()
+
+ # Validate attributes for each termination (if any)
+ for term in ('termination1', 'termination2'):
+ required_parameters = (
+ f'{term}_role', f'{term}_parent', f'{term}_termination',
+ )
+ parameters = (
+ *required_parameters,
+ f'{term}_outside_ip',
+ )
+ if any([self.cleaned_data[param] for param in parameters]):
+ for param in required_parameters:
+ if not self.cleaned_data[param]:
+ raise forms.ValidationError({
+ param: _("This parameter is required when defining a termination.")
+ })
+
+ def save(self, *args, **kwargs):
+ instance = super().save(*args, **kwargs)
+
+ # Create first termination
+ if self.cleaned_data['termination1_termination']:
+ TunnelTermination.objects.create(
+ tunnel=instance,
+ role=self.cleaned_data['termination1_role'],
+ termination=self.cleaned_data['termination1_termination'],
+ outside_ip=self.cleaned_data['termination1_outside_ip'],
+ )
+
+ # Create second termination, if defined
+ if self.cleaned_data['termination2_termination']:
+ TunnelTermination.objects.create(
+ tunnel=instance,
+ role=self.cleaned_data['termination2_role'],
+ termination=self.cleaned_data['termination2_termination'],
+ outside_ip=self.cleaned_data.get('termination2_outside_ip'),
+ )
+
+ return instance
+
+
+class TunnelTerminationForm(NetBoxModelForm):
+ tunnel = DynamicModelChoiceField(
+ queryset=Tunnel.objects.all()
+ )
+ type = forms.ChoiceField(
+ choices=TunnelTerminationTypeChoices,
+ widget=HTMXSelect(),
+ label=_('Type')
+ )
+ parent = DynamicModelChoiceField(
+ queryset=Device.objects.all(),
+ selector=True,
+ label=_('Device')
+ )
+ termination = DynamicModelChoiceField(
+ queryset=Interface.objects.all(),
+ label=_('Interface'),
+ query_params={
+ 'device_id': '$parent',
+ }
+ )
+ outside_ip = DynamicModelChoiceField(
+ queryset=IPAddress.objects.all(),
+ label=_('Outside IP'),
+ required=False,
+ query_params={
+ 'device_id': '$parent',
+ }
+ )
+
+ fieldsets = (
+ (None, ('tunnel', 'role', 'type', 'parent', 'termination', 'outside_ip', 'tags')),
+ )
+
+ class Meta:
+ model = TunnelTermination
+ fields = [
+ 'tunnel', 'role', 'termination', 'outside_ip', 'tags',
+ ]
+
+ def __init__(self, *args, initial=None, **kwargs):
+ super().__init__(*args, initial=initial, **kwargs)
+
+ if initial and initial.get('type') == TunnelTerminationTypeChoices.TYPE_VIRUTALMACHINE:
+ self.fields['parent'].label = _('Virtual Machine')
+ self.fields['parent'].queryset = VirtualMachine.objects.all()
+ self.fields['termination'].queryset = VMInterface.objects.all()
+ self.fields['termination'].widget.add_query_params({
+ 'virtual_machine_id': '$parent',
+ })
+ self.fields['outside_ip'].widget.add_query_params({
+ 'virtual_machine_id': '$parent',
+ })
+
+ if self.instance.pk:
+ self.fields['parent'].initial = self.instance.termination.parent_object
+ self.fields['termination'].initial = self.instance.termination
+
+ def clean(self):
+ super().clean()
+
+ # Set the terminated object
+ self.instance.termination = self.cleaned_data.get('termination')
+
+
+class IKEProposalForm(NetBoxModelForm):
+
+ fieldsets = (
+ (_('Proposal'), ('name', 'description', 'tags')),
+ (_('Parameters'), (
+ 'authentication_method', 'encryption_algorithm', 'authentication_algorithm', 'group', 'sa_lifetime',
+ )),
+ )
+
+ class Meta:
+ model = IKEProposal
+ fields = [
+ 'name', 'description', 'authentication_method', 'encryption_algorithm', 'authentication_algorithm', 'group',
+ 'sa_lifetime', 'comments', 'tags',
+ ]
+
+
+class IKEPolicyForm(NetBoxModelForm):
+ proposals = DynamicModelMultipleChoiceField(
+ queryset=IKEProposal.objects.all(),
+ label=_('Proposals')
+ )
+
+ fieldsets = (
+ (_('Policy'), ('name', 'description', 'tags')),
+ (_('Parameters'), ('version', 'mode', 'proposals', 'preshared_key')),
+ )
+
+ class Meta:
+ model = IKEPolicy
+ fields = [
+ 'name', 'description', 'version', 'mode', 'proposals', 'preshared_key', 'comments', 'tags',
+ ]
+
+
+class IPSecProposalForm(NetBoxModelForm):
+
+ fieldsets = (
+ (_('Proposal'), ('name', 'description', 'tags')),
+ (_('Parameters'), (
+ 'encryption_algorithm', 'authentication_algorithm', 'sa_lifetime_seconds', 'sa_lifetime_data',
+ )),
+ )
+
+ class Meta:
+ model = IPSecProposal
+ fields = [
+ 'name', 'description', 'encryption_algorithm', 'authentication_algorithm', 'sa_lifetime_seconds',
+ 'sa_lifetime_data', 'comments', 'tags',
+ ]
+
+
+class IPSecPolicyForm(NetBoxModelForm):
+ proposals = DynamicModelMultipleChoiceField(
+ queryset=IPSecProposal.objects.all(),
+ label=_('Proposals')
+ )
+
+ fieldsets = (
+ (_('Policy'), ('name', 'description', 'tags')),
+ (_('Parameters'), ('proposals', 'pfs_group')),
+ )
+
+ class Meta:
+ model = IPSecPolicy
+ fields = [
+ 'name', 'description', 'proposals', 'pfs_group', 'comments', 'tags',
+ ]
+
+
+class IPSecProfileForm(NetBoxModelForm):
+ ike_policy = DynamicModelChoiceField(
+ queryset=IKEPolicy.objects.all(),
+ label=_('IKE policy')
+ )
+ ipsec_policy = DynamicModelChoiceField(
+ queryset=IPSecPolicy.objects.all(),
+ label=_('IPSec policy')
+ )
+ comments = CommentField()
+
+ fieldsets = (
+ (_('Profile'), ('name', 'description', 'tags')),
+ (_('Parameters'), ('mode', 'ike_policy', 'ipsec_policy')),
+ )
+
+ class Meta:
+ model = IPSecProfile
+ fields = [
+ 'name', 'description', 'mode', 'ike_policy', 'ipsec_policy', 'description', 'comments', 'tags',
+ ]
+
+
+#
+# L2VPN
+#
+
+class L2VPNForm(TenancyForm, NetBoxModelForm):
+ slug = SlugField()
+ import_targets = DynamicModelMultipleChoiceField(
+ label=_('Import targets'),
+ queryset=RouteTarget.objects.all(),
+ required=False
+ )
+ export_targets = DynamicModelMultipleChoiceField(
+ label=_('Export targets'),
+ queryset=RouteTarget.objects.all(),
+ required=False
+ )
+ comments = CommentField()
+
+ fieldsets = (
+ (_('L2VPN'), ('name', 'slug', 'type', 'identifier', 'description', 'tags')),
+ (_('Route Targets'), ('import_targets', 'export_targets')),
+ (_('Tenancy'), ('tenant_group', 'tenant')),
+ )
+
+ class Meta:
+ model = L2VPN
+ fields = (
+ 'name', 'slug', 'type', 'identifier', 'import_targets', 'export_targets', 'tenant', 'description',
+ 'comments', 'tags'
+ )
+
+
+class L2VPNTerminationForm(NetBoxModelForm):
+ l2vpn = DynamicModelChoiceField(
+ queryset=L2VPN.objects.all(),
+ required=True,
+ query_params={},
+ label=_('L2VPN'),
+ fetch_trigger='open'
+ )
+ vlan = DynamicModelChoiceField(
+ queryset=VLAN.objects.all(),
+ required=False,
+ selector=True,
+ label=_('VLAN')
+ )
+ interface = DynamicModelChoiceField(
+ label=_('Interface'),
+ queryset=Interface.objects.all(),
+ required=False,
+ selector=True
+ )
+ vminterface = DynamicModelChoiceField(
+ queryset=VMInterface.objects.all(),
+ required=False,
+ selector=True,
+ label=_('Interface')
+ )
+
+ class Meta:
+ model = L2VPNTermination
+ fields = ('l2vpn', 'tags')
+
+ def __init__(self, *args, **kwargs):
+ instance = kwargs.get('instance')
+ initial = kwargs.get('initial', {}).copy()
+
+ if instance:
+ if type(instance.assigned_object) is Interface:
+ initial['interface'] = instance.assigned_object
+ elif type(instance.assigned_object) is VLAN:
+ initial['vlan'] = instance.assigned_object
+ elif type(instance.assigned_object) is VMInterface:
+ initial['vminterface'] = instance.assigned_object
+ kwargs['initial'] = initial
+
+ super().__init__(*args, **kwargs)
+
+ def clean(self):
+ super().clean()
+
+ interface = self.cleaned_data.get('interface')
+ vminterface = self.cleaned_data.get('vminterface')
+ vlan = self.cleaned_data.get('vlan')
+
+ 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).'))
+
+ self.instance.assigned_object = interface or vminterface or vlan
diff --git a/netbox/vpn/graphql/__init__.py b/netbox/vpn/graphql/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/netbox/vpn/graphql/gfk_mixins.py b/netbox/vpn/graphql/gfk_mixins.py
new file mode 100644
index 000000000..72272f7ad
--- /dev/null
+++ b/netbox/vpn/graphql/gfk_mixins.py
@@ -0,0 +1,30 @@
+import graphene
+
+from dcim.graphql.types import InterfaceType
+from dcim.models import Interface
+from ipam.graphql.types import VLANType
+from ipam.models import VLAN
+from virtualization.graphql.types import VMInterfaceType
+from virtualization.models import VMInterface
+
+__all__ = (
+ 'L2VPNAssignmentType',
+)
+
+
+class L2VPNAssignmentType(graphene.Union):
+ class Meta:
+ types = (
+ InterfaceType,
+ VLANType,
+ VMInterfaceType,
+ )
+
+ @classmethod
+ def resolve_type(cls, instance, info):
+ if type(instance) is Interface:
+ return InterfaceType
+ if type(instance) is VLAN:
+ return VLANType
+ if type(instance) is VMInterface:
+ return VMInterfaceType
diff --git a/netbox/vpn/graphql/schema.py b/netbox/vpn/graphql/schema.py
new file mode 100644
index 000000000..6737957d4
--- /dev/null
+++ b/netbox/vpn/graphql/schema.py
@@ -0,0 +1,69 @@
+import graphene
+
+from netbox.graphql.fields import ObjectField, ObjectListField
+from utilities.graphql_optimizer import gql_query_optimizer
+from vpn import models
+from .types import *
+
+
+class VPNQuery(graphene.ObjectType):
+
+ ike_policy = ObjectField(IKEPolicyType)
+ ike_policy_list = ObjectListField(IKEPolicyType)
+
+ def resolve_ike_policy_list(root, info, **kwargs):
+ return gql_query_optimizer(models.IKEPolicy.objects.all(), info)
+
+ ike_proposal = ObjectField(IKEProposalType)
+ ike_proposal_list = ObjectListField(IKEProposalType)
+
+ def resolve_ike_proposal_list(root, info, **kwargs):
+ return gql_query_optimizer(models.IKEProposal.objects.all(), info)
+
+ ipsec_policy = ObjectField(IPSecPolicyType)
+ ipsec_policy_list = ObjectListField(IPSecPolicyType)
+
+ def resolve_ipsec_policy_list(root, info, **kwargs):
+ return gql_query_optimizer(models.IPSecPolicy.objects.all(), info)
+
+ ipsec_profile = ObjectField(IPSecProfileType)
+ ipsec_profile_list = ObjectListField(IPSecProfileType)
+
+ def resolve_ipsec_profile_list(root, info, **kwargs):
+ return gql_query_optimizer(models.IPSecProfile.objects.all(), info)
+
+ ipsec_proposal = ObjectField(IPSecProposalType)
+ ipsec_proposal_list = ObjectListField(IPSecProposalType)
+
+ def resolve_ipsec_proposal_list(root, info, **kwargs):
+ return gql_query_optimizer(models.IPSecProposal.objects.all(), info)
+
+ l2vpn = ObjectField(L2VPNType)
+ l2vpn_list = ObjectListField(L2VPNType)
+
+ def resolve_l2vpn_list(root, info, **kwargs):
+ return gql_query_optimizer(models.L2VPN.objects.all(), info)
+
+ l2vpn_termination = ObjectField(L2VPNTerminationType)
+ l2vpn_termination_list = ObjectListField(L2VPNTerminationType)
+
+ def resolve_l2vpn_termination_list(root, info, **kwargs):
+ return gql_query_optimizer(models.L2VPNTermination.objects.all(), info)
+
+ tunnel = ObjectField(TunnelType)
+ tunnel_list = ObjectListField(TunnelType)
+
+ def resolve_tunnel_list(root, info, **kwargs):
+ return gql_query_optimizer(models.Tunnel.objects.all(), info)
+
+ tunnel_group = ObjectField(TunnelGroupType)
+ tunnel_group_list = ObjectListField(TunnelGroupType)
+
+ def resolve_tunnel_group_list(root, info, **kwargs):
+ return gql_query_optimizer(models.TunnelGroup.objects.all(), info)
+
+ tunnel_termination = ObjectField(TunnelTerminationType)
+ tunnel_termination_list = ObjectListField(TunnelTerminationType)
+
+ def resolve_tunnel_termination_list(root, info, **kwargs):
+ return gql_query_optimizer(models.TunnelTermination.objects.all(), info)
diff --git a/netbox/vpn/graphql/types.py b/netbox/vpn/graphql/types.py
new file mode 100644
index 000000000..0bfebb441
--- /dev/null
+++ b/netbox/vpn/graphql/types.py
@@ -0,0 +1,98 @@
+import graphene
+
+from extras.graphql.mixins import ContactsMixin, CustomFieldsMixin, TagsMixin
+from netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObjectType
+from vpn import filtersets, models
+
+__all__ = (
+ 'IKEPolicyType',
+ 'IKEProposalType',
+ 'IPSecPolicyType',
+ 'IPSecProfileType',
+ 'IPSecProposalType',
+ 'L2VPNType',
+ 'L2VPNTerminationType',
+ 'TunnelGroupType',
+ 'TunnelTerminationType',
+ 'TunnelType',
+)
+
+
+class TunnelGroupType(OrganizationalObjectType):
+
+ class Meta:
+ model = models.TunnelGroup
+ fields = '__all__'
+ filterset_class = filtersets.TunnelGroupFilterSet
+
+
+class TunnelTerminationType(CustomFieldsMixin, TagsMixin, ObjectType):
+
+ class Meta:
+ model = models.TunnelTermination
+ fields = '__all__'
+ filterset_class = filtersets.TunnelTerminationFilterSet
+
+
+class TunnelType(NetBoxObjectType):
+
+ class Meta:
+ model = models.Tunnel
+ fields = '__all__'
+ filterset_class = filtersets.TunnelFilterSet
+
+
+class IKEProposalType(OrganizationalObjectType):
+
+ class Meta:
+ model = models.IKEProposal
+ fields = '__all__'
+ filterset_class = filtersets.IKEProposalFilterSet
+
+
+class IKEPolicyType(OrganizationalObjectType):
+
+ class Meta:
+ model = models.IKEPolicy
+ fields = '__all__'
+ filterset_class = filtersets.IKEPolicyFilterSet
+
+
+class IPSecProposalType(OrganizationalObjectType):
+
+ class Meta:
+ model = models.IPSecProposal
+ fields = '__all__'
+ filterset_class = filtersets.IPSecProposalFilterSet
+
+
+class IPSecPolicyType(OrganizationalObjectType):
+
+ class Meta:
+ model = models.IPSecPolicy
+ fields = '__all__'
+ filterset_class = filtersets.IPSecPolicyFilterSet
+
+
+class IPSecProfileType(OrganizationalObjectType):
+
+ class Meta:
+ model = models.IPSecProfile
+ fields = '__all__'
+ filterset_class = filtersets.IPSecProfileFilterSet
+
+
+class L2VPNType(ContactsMixin, NetBoxObjectType):
+ class Meta:
+ model = models.L2VPN
+ fields = '__all__'
+ filtersets_class = filtersets.L2VPNFilterSet
+
+
+class L2VPNTerminationType(NetBoxObjectType):
+ assigned_object = graphene.Field('vpn.graphql.gfk_mixins.L2VPNAssignmentType')
+
+ class Meta:
+ model = models.L2VPNTermination
+ exclude = ('assigned_object_type', 'assigned_object_id')
+ filtersets_class = filtersets.L2VPNTerminationFilterSet
diff --git a/netbox/vpn/migrations/0001_initial.py b/netbox/vpn/migrations/0001_initial.py
new file mode 100644
index 000000000..681474837
--- /dev/null
+++ b/netbox/vpn/migrations/0001_initial.py
@@ -0,0 +1,230 @@
+from django.db import migrations, models
+import django.db.models.deletion
+import taggit.managers
+import utilities.json
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('contenttypes', '0002_remove_content_type_name'),
+ ('extras', '0099_cachedvalue_ordering'),
+ ('ipam', '0067_ipaddress_index_host'),
+ ('tenancy', '0012_contactassignment_custom_fields'),
+ ]
+
+ operations = [
+ # IKE
+ migrations.CreateModel(
+ name='IKEProposal',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+ ('created', models.DateTimeField(auto_now_add=True, null=True)),
+ ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+ ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+ ('description', models.CharField(blank=True, max_length=200)),
+ ('comments', models.TextField(blank=True)),
+ ('name', models.CharField(max_length=100, unique=True)),
+ ('authentication_method', models.CharField()),
+ ('encryption_algorithm', models.CharField()),
+ ('authentication_algorithm', models.CharField(blank=True)),
+ ('group', models.PositiveSmallIntegerField()),
+ ('sa_lifetime', models.PositiveIntegerField(blank=True, null=True)),
+ ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+ ],
+ options={
+ 'verbose_name': 'IKE proposal',
+ 'verbose_name_plural': 'IKE proposals',
+ 'ordering': ('name',),
+ },
+ ),
+ migrations.CreateModel(
+ name='IKEPolicy',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+ ('created', models.DateTimeField(auto_now_add=True, null=True)),
+ ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+ ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+ ('description', models.CharField(blank=True, max_length=200)),
+ ('comments', models.TextField(blank=True)),
+ ('name', models.CharField(max_length=100, unique=True)),
+ ('version', models.PositiveSmallIntegerField(default=2)),
+ ('mode', models.CharField()),
+ ('preshared_key', models.TextField(blank=True)),
+ ],
+ options={
+ 'verbose_name': 'IKE policy',
+ 'verbose_name_plural': 'IKE policies',
+ 'ordering': ('name',),
+ },
+ ),
+ migrations.AddField(
+ model_name='ikepolicy',
+ name='proposals',
+ field=models.ManyToManyField(related_name='ike_policies', to='vpn.ikeproposal'),
+ ),
+ migrations.AddField(
+ model_name='ikepolicy',
+ name='tags',
+ field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+ ),
+
+ # IPSec
+ migrations.CreateModel(
+ name='IPSecProposal',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+ ('created', models.DateTimeField(auto_now_add=True, null=True)),
+ ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+ ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+ ('description', models.CharField(blank=True, max_length=200)),
+ ('comments', models.TextField(blank=True)),
+ ('name', models.CharField(max_length=100, unique=True)),
+ ('encryption_algorithm', models.CharField(blank=True)),
+ ('authentication_algorithm', models.CharField(blank=True)),
+ ('sa_lifetime_seconds', models.PositiveIntegerField(blank=True, null=True)),
+ ('sa_lifetime_data', models.PositiveIntegerField(blank=True, null=True)),
+ ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+ ],
+ options={
+ 'verbose_name': 'IPSec proposal',
+ 'verbose_name_plural': 'IPSec proposals',
+ 'ordering': ('name',),
+ },
+ ),
+ migrations.CreateModel(
+ name='IPSecPolicy',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+ ('created', models.DateTimeField(auto_now_add=True, null=True)),
+ ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+ ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+ ('description', models.CharField(blank=True, max_length=200)),
+ ('comments', models.TextField(blank=True)),
+ ('name', models.CharField(max_length=100, unique=True)),
+ ('pfs_group', models.PositiveSmallIntegerField(blank=True, null=True)),
+ ],
+ options={
+ 'verbose_name': 'IPSec policy',
+ 'verbose_name_plural': 'IPSec policies',
+ 'ordering': ('name',),
+ },
+ ),
+ migrations.AddField(
+ model_name='ipsecpolicy',
+ name='proposals',
+ field=models.ManyToManyField(related_name='ipsec_policies', to='vpn.ipsecproposal'),
+ ),
+ migrations.AddField(
+ model_name='ipsecpolicy',
+ name='tags',
+ field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+ ),
+ migrations.CreateModel(
+ name='IPSecProfile',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+ ('created', models.DateTimeField(auto_now_add=True, null=True)),
+ ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+ ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+ ('description', models.CharField(blank=True, max_length=200)),
+ ('comments', models.TextField(blank=True)),
+ ('name', models.CharField(max_length=100, unique=True)),
+ ('mode', models.CharField()),
+ ('ike_policy', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='ipsec_profiles', to='vpn.ikepolicy')),
+ ('ipsec_policy', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='ipsec_profiles', to='vpn.ipsecpolicy')),
+ ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+ ],
+ options={
+ 'verbose_name': 'IPSec profile',
+ 'verbose_name_plural': 'IPSec profiles',
+ 'ordering': ('name',),
+ },
+ ),
+
+ # Tunnels
+ migrations.CreateModel(
+ name='TunnelGroup',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+ ('created', models.DateTimeField(auto_now_add=True, null=True)),
+ ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+ ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+ ('name', models.CharField(max_length=100, unique=True)),
+ ('slug', models.SlugField(max_length=100, unique=True)),
+ ('description', models.CharField(blank=True, max_length=200)),
+ ],
+ options={
+ 'verbose_name': 'tunnel group',
+ 'verbose_name_plural': 'tunnel groups',
+ 'ordering': ('name',),
+ },
+ ),
+ migrations.AddField(
+ model_name='tunnelgroup',
+ name='tags',
+ field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+ ),
+ migrations.CreateModel(
+ name='Tunnel',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+ ('created', models.DateTimeField(auto_now_add=True, null=True)),
+ ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+ ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+ ('description', models.CharField(blank=True, max_length=200)),
+ ('comments', models.TextField(blank=True)),
+ ('name', models.CharField(max_length=100, unique=True)),
+ ('status', models.CharField(default='active', max_length=50)),
+ ('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tunnels', to='vpn.tunnelgroup')),
+ ('encapsulation', models.CharField(max_length=50)),
+ ('tunnel_id', models.PositiveBigIntegerField(blank=True, null=True)),
+ ('ipsec_profile', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tunnels', to='vpn.ipsecprofile')),
+ ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+ ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tunnels', to='tenancy.tenant')),
+ ],
+ options={
+ 'verbose_name': 'tunnel',
+ 'verbose_name_plural': 'tunnels',
+ 'ordering': ('name',),
+ },
+ ),
+ migrations.AddConstraint(
+ model_name='tunnel',
+ constraint=models.UniqueConstraint(fields=('group', 'name'), name='vpn_tunnel_group_name'),
+ ),
+ migrations.AddConstraint(
+ model_name='tunnel',
+ constraint=models.UniqueConstraint(condition=models.Q(('group__isnull', True)), fields=('name',), name='vpn_tunnel_name'),
+ ),
+ migrations.CreateModel(
+ name='TunnelTermination',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+ ('created', models.DateTimeField(auto_now_add=True, null=True)),
+ ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+ ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+ ('role', models.CharField(default='peer', max_length=50)),
+ ('termination_id', models.PositiveBigIntegerField(blank=True, null=True)),
+ ('termination_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')),
+ ('outside_ip', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tunnel_termination', to='ipam.ipaddress')),
+ ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+ ('tunnel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='vpn.tunnel')),
+ ],
+ options={
+ 'verbose_name': 'tunnel termination',
+ 'verbose_name_plural': 'tunnel terminations',
+ 'ordering': ('tunnel', 'role', 'pk'),
+ },
+ ),
+ migrations.AddIndex(
+ model_name='tunneltermination',
+ index=models.Index(fields=['termination_type', 'termination_id'], name='vpn_tunnelt_termina_c1f04b_idx'),
+ ),
+ migrations.AddConstraint(
+ model_name='tunneltermination',
+ constraint=models.UniqueConstraint(fields=('termination_type', 'termination_id'), name='vpn_tunneltermination_termination', violation_error_message='An object may be terminated to only one tunnel at a time.'),
+ ),
+ ]
diff --git a/netbox/vpn/migrations/0002_move_l2vpn.py b/netbox/vpn/migrations/0002_move_l2vpn.py
new file mode 100644
index 000000000..b83ea4655
--- /dev/null
+++ b/netbox/vpn/migrations/0002_move_l2vpn.py
@@ -0,0 +1,77 @@
+from django.db import migrations, models
+import django.db.models.deletion
+import taggit.managers
+import utilities.json
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('extras', '0099_cachedvalue_ordering'),
+ ('contenttypes', '0002_remove_content_type_name'),
+ ('tenancy', '0012_contactassignment_custom_fields'),
+ ('ipam', '0068_move_l2vpn'),
+ ('vpn', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.SeparateDatabaseAndState(
+ state_operations=[
+ migrations.CreateModel(
+ name='L2VPN',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+ ('created', models.DateTimeField(auto_now_add=True, null=True)),
+ ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+ ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+ ('description', models.CharField(blank=True, max_length=200)),
+ ('comments', models.TextField(blank=True)),
+ ('name', models.CharField(max_length=100, unique=True)),
+ ('slug', models.SlugField(max_length=100, unique=True)),
+ ('type', models.CharField(max_length=50)),
+ ('identifier', models.BigIntegerField(blank=True, null=True)),
+ ('export_targets', models.ManyToManyField(blank=True, related_name='exporting_l2vpns', to='ipam.routetarget')),
+ ('import_targets', models.ManyToManyField(blank=True, related_name='importing_l2vpns', to='ipam.routetarget')),
+ ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+ ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='l2vpns', to='tenancy.tenant')),
+ ],
+ options={
+ 'verbose_name': 'L2VPN',
+ 'verbose_name_plural': 'L2VPNs',
+ 'ordering': ('name', 'identifier'),
+ },
+ ),
+ migrations.CreateModel(
+ name='L2VPNTermination',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+ ('created', models.DateTimeField(auto_now_add=True, null=True)),
+ ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+ ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+ ('assigned_object_id', models.PositiveBigIntegerField()),
+ ('assigned_object_type', models.ForeignKey(limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'dcim'), ('model', 'interface')), models.Q(('app_label', 'ipam'), ('model', 'vlan')), models.Q(('app_label', 'virtualization'), ('model', 'vminterface')), _connector='OR')), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')),
+ ('l2vpn', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='vpn.l2vpn')),
+ ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+ ],
+ options={
+ 'verbose_name': 'L2VPN termination',
+ 'verbose_name_plural': 'L2VPN terminations',
+ 'ordering': ('l2vpn',),
+ },
+ ),
+ ],
+ # Tables have been renamed from ipam
+ database_operations=[],
+ ),
+ migrations.AddConstraint(
+ model_name='l2vpntermination',
+ constraint=models.UniqueConstraint(
+ fields=('assigned_object_type', 'assigned_object_id'),
+ name='vpn_l2vpntermination_assigned_object'
+ ),
+ ),
+ migrations.AddIndex(
+ model_name='l2vpntermination',
+ index=models.Index(fields=['assigned_object_type', 'assigned_object_id'], name='vpn_l2vpnte_assigne_9c55f8_idx'),
+ ),
+ ]
diff --git a/netbox/vpn/migrations/__init__.py b/netbox/vpn/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/netbox/vpn/models/__init__.py b/netbox/vpn/models/__init__.py
new file mode 100644
index 000000000..2e76b980b
--- /dev/null
+++ b/netbox/vpn/models/__init__.py
@@ -0,0 +1,3 @@
+from .crypto import *
+from .l2vpn import *
+from .tunnels import *
diff --git a/netbox/vpn/models/crypto.py b/netbox/vpn/models/crypto.py
new file mode 100644
index 000000000..f89c555e4
--- /dev/null
+++ b/netbox/vpn/models/crypto.py
@@ -0,0 +1,245 @@
+from django.core.exceptions import ValidationError
+from django.db import models
+from django.urls import reverse
+from django.utils.translation import gettext_lazy as _
+
+from netbox.models import PrimaryModel
+from vpn.choices import *
+
+__all__ = (
+ 'IKEPolicy',
+ 'IKEProposal',
+ 'IPSecPolicy',
+ 'IPSecProfile',
+ 'IPSecProposal',
+)
+
+
+#
+# IKE
+#
+
+class IKEProposal(PrimaryModel):
+ name = models.CharField(
+ verbose_name=_('name'),
+ max_length=100,
+ unique=True
+ )
+ authentication_method = models.CharField(
+ verbose_name=('authentication method'),
+ choices=AuthenticationMethodChoices
+ )
+ encryption_algorithm = models.CharField(
+ verbose_name=_('encryption algorithm'),
+ choices=EncryptionAlgorithmChoices
+ )
+ authentication_algorithm = models.CharField(
+ verbose_name=_('authentication algorithm'),
+ choices=AuthenticationAlgorithmChoices,
+ blank=True
+ )
+ group = models.PositiveSmallIntegerField(
+ verbose_name=_('group'),
+ choices=DHGroupChoices,
+ help_text=_('Diffie-Hellman group ID')
+ )
+ sa_lifetime = models.PositiveIntegerField(
+ verbose_name=_('SA lifetime'),
+ blank=True,
+ null=True,
+ help_text=_('Security association lifetime (in seconds)')
+ )
+
+ clone_fields = (
+ 'authentication_method', 'encryption_algorithm', 'authentication_algorithm', 'group', 'sa_lifetime',
+ )
+
+ class Meta:
+ ordering = ('name',)
+ verbose_name = _('IKE proposal')
+ verbose_name_plural = _('IKE proposals')
+
+ def __str__(self):
+ return self.name
+
+ def get_absolute_url(self):
+ return reverse('vpn:ikeproposal', args=[self.pk])
+
+
+class IKEPolicy(PrimaryModel):
+ name = models.CharField(
+ verbose_name=_('name'),
+ max_length=100,
+ unique=True
+ )
+ version = models.PositiveSmallIntegerField(
+ verbose_name=_('version'),
+ choices=IKEVersionChoices,
+ default=IKEVersionChoices.VERSION_2
+ )
+ mode = models.CharField(
+ verbose_name=_('mode'),
+ choices=IKEModeChoices
+ )
+ proposals = models.ManyToManyField(
+ to='vpn.IKEProposal',
+ related_name='ike_policies',
+ verbose_name=_('proposals')
+ )
+ preshared_key = models.TextField(
+ verbose_name=_('pre-shared key'),
+ blank=True
+ )
+
+ clone_fields = (
+ 'version', 'mode', 'proposals',
+ )
+ prerequisite_models = (
+ 'vpn.IKEProposal',
+ )
+
+ class Meta:
+ ordering = ('name',)
+ verbose_name = _('IKE policy')
+ verbose_name_plural = _('IKE policies')
+
+ def __str__(self):
+ return self.name
+
+ def get_absolute_url(self):
+ return reverse('vpn:ikepolicy', args=[self.pk])
+
+
+#
+# IPSec
+#
+
+class IPSecProposal(PrimaryModel):
+ name = models.CharField(
+ verbose_name=_('name'),
+ max_length=100,
+ unique=True
+ )
+ encryption_algorithm = models.CharField(
+ verbose_name=_('encryption'),
+ choices=EncryptionAlgorithmChoices,
+ blank=True
+ )
+ authentication_algorithm = models.CharField(
+ verbose_name=_('authentication'),
+ choices=AuthenticationAlgorithmChoices,
+ blank=True
+ )
+ sa_lifetime_seconds = models.PositiveIntegerField(
+ verbose_name=_('SA lifetime (seconds)'),
+ blank=True,
+ null=True,
+ help_text=_('Security association lifetime (seconds)')
+ )
+ sa_lifetime_data = models.PositiveIntegerField(
+ verbose_name=_('SA lifetime (KB)'),
+ blank=True,
+ null=True,
+ help_text=_('Security association lifetime (in kilobytes)')
+ )
+
+ clone_fields = (
+ 'encryption_algorithm', 'authentication_algorithm', 'sa_lifetime_seconds', 'sa_lifetime_data',
+ )
+
+ class Meta:
+ ordering = ('name',)
+ verbose_name = _('IPSec proposal')
+ verbose_name_plural = _('IPSec proposals')
+
+ def __str__(self):
+ return self.name
+
+ def get_absolute_url(self):
+ return reverse('vpn:ipsecproposal', args=[self.pk])
+
+ def clean(self):
+ super().clean()
+
+ # Encryption and/or authentication algorithm must be defined
+ if not self.encryption_algorithm and not self.authentication_algorithm:
+ raise ValidationError(_("Encryption and/or authentication algorithm must be defined"))
+
+
+class IPSecPolicy(PrimaryModel):
+ name = models.CharField(
+ verbose_name=_('name'),
+ max_length=100,
+ unique=True
+ )
+ proposals = models.ManyToManyField(
+ to='vpn.IPSecProposal',
+ related_name='ipsec_policies',
+ verbose_name=_('proposals')
+ )
+ pfs_group = models.PositiveSmallIntegerField(
+ verbose_name=_('PFS group'),
+ choices=DHGroupChoices,
+ blank=True,
+ null=True,
+ help_text=_('Diffie-Hellman group for Perfect Forward Secrecy')
+ )
+
+ clone_fields = (
+ 'proposals', 'pfs_group',
+ )
+ prerequisite_models = (
+ 'vpn.IPSecProposal',
+ )
+
+ class Meta:
+ ordering = ('name',)
+ verbose_name = _('IPSec policy')
+ verbose_name_plural = _('IPSec policies')
+
+ def __str__(self):
+ return self.name
+
+ def get_absolute_url(self):
+ return reverse('vpn:ipsecpolicy', args=[self.pk])
+
+
+class IPSecProfile(PrimaryModel):
+ name = models.CharField(
+ verbose_name=_('name'),
+ max_length=100,
+ unique=True
+ )
+ mode = models.CharField(
+ verbose_name=_('mode'),
+ choices=IPSecModeChoices
+ )
+ ike_policy = models.ForeignKey(
+ to='vpn.IKEPolicy',
+ on_delete=models.PROTECT,
+ related_name='ipsec_profiles'
+ )
+ ipsec_policy = models.ForeignKey(
+ to='vpn.IPSecPolicy',
+ on_delete=models.PROTECT,
+ related_name='ipsec_profiles'
+ )
+
+ clone_fields = (
+ 'mode', 'ike_policy', 'ipsec_policy',
+ )
+ prerequisite_models = (
+ 'vpn.IKEPolicy',
+ 'vpn.IPSecPolicy',
+ )
+
+ class Meta:
+ ordering = ('name',)
+ verbose_name = _('IPSec profile')
+ verbose_name_plural = _('IPSec profiles')
+
+ def __str__(self):
+ return self.name
+
+ def get_absolute_url(self):
+ return reverse('vpn:ipsecprofile', args=[self.pk])
diff --git a/netbox/ipam/models/l2vpn.py b/netbox/vpn/models/l2vpn.py
similarity index 90%
rename from netbox/ipam/models/l2vpn.py
rename to netbox/vpn/models/l2vpn.py
index 3072fc6c3..31d267113 100644
--- a/netbox/ipam/models/l2vpn.py
+++ b/netbox/vpn/models/l2vpn.py
@@ -1,15 +1,15 @@
from django.contrib.contenttypes.fields import GenericForeignKey
-from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
-from ipam.choices import L2VPNTypeChoices
-from ipam.constants import L2VPN_ASSIGNMENT_MODELS
+from core.models import ContentType
from netbox.models import NetBoxModel, PrimaryModel
from netbox.models.features import ContactsMixin
+from vpn.choices import L2VPNTypeChoices
+from vpn.constants import L2VPN_ASSIGNMENT_MODELS
__all__ = (
'L2VPN',
@@ -69,7 +69,7 @@ class L2VPN(ContactsMixin, PrimaryModel):
return f'{self.name}'
def get_absolute_url(self):
- return reverse('ipam:l2vpn', args=[self.pk])
+ return reverse('vpn:l2vpn', args=[self.pk])
@cached_property
def can_add_termination(self):
@@ -81,12 +81,12 @@ class L2VPN(ContactsMixin, PrimaryModel):
class L2VPNTermination(NetBoxModel):
l2vpn = models.ForeignKey(
- to='ipam.L2VPN',
+ to='vpn.L2VPN',
on_delete=models.CASCADE,
related_name='terminations'
)
assigned_object_type = models.ForeignKey(
- to=ContentType,
+ to='contenttypes.ContentType',
limit_choices_to=L2VPN_ASSIGNMENT_MODELS,
on_delete=models.PROTECT,
related_name='+'
@@ -99,15 +99,18 @@ class L2VPNTermination(NetBoxModel):
clone_fields = ('l2vpn',)
prerequisite_models = (
- 'ipam.L2VPN',
+ 'vpn.L2VPN',
)
class Meta:
ordering = ('l2vpn',)
+ indexes = (
+ models.Index(fields=('assigned_object_type', 'assigned_object_id')),
+ )
constraints = (
models.UniqueConstraint(
fields=('assigned_object_type', 'assigned_object_id'),
- name='ipam_l2vpntermination_assigned_object'
+ name='vpn_l2vpntermination_assigned_object'
),
)
verbose_name = _('L2VPN termination')
@@ -119,7 +122,7 @@ class L2VPNTermination(NetBoxModel):
return super().__str__()
def get_absolute_url(self):
- return reverse('ipam:l2vpntermination', args=[self.pk])
+ return reverse('vpn:l2vpntermination', args=[self.pk])
def clean(self):
# Only check is assigned_object is set. Required otherwise we have an Integrity Error thrown.
diff --git a/netbox/vpn/models/tunnels.py b/netbox/vpn/models/tunnels.py
new file mode 100644
index 000000000..be1e40142
--- /dev/null
+++ b/netbox/vpn/models/tunnels.py
@@ -0,0 +1,183 @@
+from django.contrib.contenttypes.fields import GenericForeignKey
+from django.core.exceptions import ValidationError
+from django.db import models
+from django.db.models import Q
+from django.urls import reverse
+from django.utils.translation import gettext_lazy as _
+
+from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel
+from netbox.models.features import CustomFieldsMixin, CustomLinksMixin, TagsMixin
+from vpn.choices import *
+
+__all__ = (
+ 'Tunnel',
+ 'TunnelGroup',
+ 'TunnelTermination',
+)
+
+
+class TunnelGroup(OrganizationalModel):
+ """
+ An administrative grouping of Tunnels. This can be used to correlate peer-to-peer tunnels which form a mesh,
+ for example.
+ """
+ class Meta:
+ ordering = ('name',)
+ verbose_name = _('tunnel group')
+ verbose_name_plural = _('tunnel groups')
+
+ def get_absolute_url(self):
+ return reverse('vpn:tunnelgroup', args=[self.pk])
+
+
+class Tunnel(PrimaryModel):
+ name = models.CharField(
+ verbose_name=_('name'),
+ max_length=100,
+ unique=True
+ )
+ status = models.CharField(
+ verbose_name=_('status'),
+ max_length=50,
+ choices=TunnelStatusChoices,
+ default=TunnelStatusChoices.STATUS_ACTIVE
+ )
+ group = models.ForeignKey(
+ to='vpn.TunnelGroup',
+ on_delete=models.PROTECT,
+ related_name='tunnels',
+ blank=True,
+ null=True
+ )
+ encapsulation = models.CharField(
+ verbose_name=_('encapsulation'),
+ max_length=50,
+ choices=TunnelEncapsulationChoices
+ )
+ ipsec_profile = models.ForeignKey(
+ to='vpn.IPSecProfile',
+ on_delete=models.PROTECT,
+ related_name='tunnels',
+ blank=True,
+ null=True
+ )
+ tenant = models.ForeignKey(
+ to='tenancy.Tenant',
+ on_delete=models.PROTECT,
+ related_name='tunnels',
+ blank=True,
+ null=True
+ )
+ tunnel_id = models.PositiveBigIntegerField(
+ verbose_name=_('tunnel ID'),
+ blank=True,
+ null=True
+ )
+
+ clone_fields = (
+ 'status', 'encapsulation', 'ipsec_profile', 'tenant',
+ )
+
+ class Meta:
+ ordering = ('name',)
+ constraints = (
+ models.UniqueConstraint(
+ fields=('group', 'name'),
+ name='%(app_label)s_%(class)s_group_name'
+ ),
+ models.UniqueConstraint(
+ fields=('name',),
+ name='%(app_label)s_%(class)s_name',
+ condition=Q(group__isnull=True)
+ ),
+ )
+ verbose_name = _('tunnel')
+ verbose_name_plural = _('tunnels')
+
+ def __str__(self):
+ return self.name
+
+ def get_absolute_url(self):
+ return reverse('vpn:tunnel', args=[self.pk])
+
+ def get_status_color(self):
+ return TunnelStatusChoices.colors.get(self.status)
+
+
+class TunnelTermination(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ChangeLoggedModel):
+ tunnel = models.ForeignKey(
+ to='vpn.Tunnel',
+ on_delete=models.CASCADE,
+ related_name='terminations'
+ )
+ role = models.CharField(
+ verbose_name=_('role'),
+ max_length=50,
+ choices=TunnelTerminationRoleChoices,
+ default=TunnelTerminationRoleChoices.ROLE_PEER
+ )
+ termination_type = models.ForeignKey(
+ to='contenttypes.ContentType',
+ on_delete=models.PROTECT,
+ related_name='+'
+ )
+ termination_id = models.PositiveBigIntegerField(
+ blank=True,
+ null=True
+ )
+ termination = GenericForeignKey(
+ ct_field='termination_type',
+ fk_field='termination_id'
+ )
+ outside_ip = models.OneToOneField(
+ to='ipam.IPAddress',
+ on_delete=models.PROTECT,
+ related_name='tunnel_termination',
+ blank=True,
+ null=True
+ )
+
+ prerequisite_models = (
+ 'vpn.Tunnel',
+ )
+
+ class Meta:
+ ordering = ('tunnel', 'role', 'pk')
+ indexes = (
+ models.Index(fields=('termination_type', 'termination_id')),
+ )
+ constraints = (
+ models.UniqueConstraint(
+ fields=('termination_type', 'termination_id'),
+ name='%(app_label)s_%(class)s_termination',
+ violation_error_message=_("An object may be terminated to only one tunnel at a time.")
+ ),
+ )
+ verbose_name = _('tunnel termination')
+ verbose_name_plural = _('tunnel terminations')
+
+ def __str__(self):
+ return f'{self.tunnel}: Termination {self.pk}'
+
+ def get_absolute_url(self):
+ return reverse('vpn:tunneltermination', args=[self.pk])
+
+ def get_role_color(self):
+ return TunnelTerminationRoleChoices.colors.get(self.role)
+
+ def clean(self):
+ super().clean()
+
+ # Check that the selected termination object is not already attached to a Tunnel
+ if getattr(self.termination, 'tunnel_termination', None) and self.termination.tunnel_termination.pk != self.pk:
+ raise ValidationError({
+ 'termination': _("{name} is already attached to a tunnel ({tunnel}).").format(
+ name=self.termination.name,
+ tunnel=self.termination.tunnel_termination.tunnel
+ )
+ })
+
+ def to_objectchange(self, action):
+ objectchange = super().to_objectchange(action)
+ objectchange.related_object = self.tunnel
+ return objectchange
diff --git a/netbox/vpn/search.py b/netbox/vpn/search.py
new file mode 100644
index 000000000..066bc68bb
--- /dev/null
+++ b/netbox/vpn/search.py
@@ -0,0 +1,81 @@
+from netbox.search import SearchIndex, register_search
+from . import models
+
+
+@register_search
+class TunnelIndex(SearchIndex):
+ model = models.Tunnel
+ fields = (
+ ('name', 100),
+ ('tunnel_id', 300),
+ ('description', 500),
+ ('comments', 5000),
+ )
+ display_attrs = ('group', 'status', 'encapsulation', 'tenant', 'tunnel_id', 'description')
+
+
+@register_search
+class IKEProposalIndex(SearchIndex):
+ model = models.IKEProposal
+ fields = (
+ ('name', 100),
+ ('description', 500),
+ ('comments', 5000),
+ )
+ display_attrs = ('description',)
+
+
+@register_search
+class IKEPolicyIndex(SearchIndex):
+ model = models.IKEPolicy
+ fields = (
+ ('name', 100),
+ ('description', 500),
+ ('comments', 5000),
+ )
+ display_attrs = ('description',)
+
+
+@register_search
+class IPSecProposalIndex(SearchIndex):
+ model = models.IPSecProposal
+ fields = (
+ ('name', 100),
+ ('description', 500),
+ ('comments', 5000),
+ )
+ display_attrs = ('description',)
+
+
+@register_search
+class IPSecPolicyIndex(SearchIndex):
+ model = models.IPSecPolicy
+ fields = (
+ ('name', 100),
+ ('description', 500),
+ ('comments', 5000),
+ )
+ display_attrs = ('description',)
+
+
+@register_search
+class IPSecProfileIndex(SearchIndex):
+ model = models.IPSecProfile
+ fields = (
+ ('name', 100),
+ ('description', 500),
+ ('comments', 5000),
+ )
+ display_attrs = ('description',)
+
+
+@register_search
+class L2VPNIndex(SearchIndex):
+ model = models.L2VPN
+ fields = (
+ ('name', 100),
+ ('slug', 110),
+ ('description', 500),
+ ('comments', 5000),
+ )
+ display_attrs = ('type', 'identifier', 'tenant', 'description')
diff --git a/netbox/vpn/tables/__init__.py b/netbox/vpn/tables/__init__.py
new file mode 100644
index 000000000..2e76b980b
--- /dev/null
+++ b/netbox/vpn/tables/__init__.py
@@ -0,0 +1,3 @@
+from .crypto import *
+from .l2vpn import *
+from .tunnels import *
diff --git a/netbox/vpn/tables/crypto.py b/netbox/vpn/tables/crypto.py
new file mode 100644
index 000000000..5e102db24
--- /dev/null
+++ b/netbox/vpn/tables/crypto.py
@@ -0,0 +1,185 @@
+import django_tables2 as tables
+from django.utils.translation import gettext_lazy as _
+
+from netbox.tables import NetBoxTable, columns
+from vpn.models import *
+
+__all__ = (
+ 'IKEPolicyTable',
+ 'IKEProposalTable',
+ 'IPSecPolicyTable',
+ 'IPSecProposalTable',
+ 'IPSecProfileTable',
+)
+
+
+class IKEProposalTable(NetBoxTable):
+ name = tables.Column(
+ verbose_name=_('Name'),
+ linkify=True
+ )
+ authentication_method = tables.Column(
+ verbose_name=_('Authentication Method')
+ )
+ encryption_algorithm = tables.Column(
+ verbose_name=_('Encryption Algorithm')
+ )
+ authentication_algorithm = tables.Column(
+ verbose_name=_('Authentication Algorithm')
+ )
+ group = tables.Column(
+ verbose_name=_('Group')
+ )
+ sa_lifetime = tables.Column(
+ verbose_name=_('SA Lifetime')
+ )
+ comments = columns.MarkdownColumn(
+ verbose_name=_('Comments'),
+ )
+ tags = columns.TagColumn(
+ url_name='vpn:ikeproposal_list'
+ )
+
+ class Meta(NetBoxTable.Meta):
+ model = IKEProposal
+ fields = (
+ 'pk', 'id', 'name', 'authentication_method', 'encryption_algorithm', 'authentication_algorithm',
+ 'group', 'sa_lifetime', 'description', 'comments', 'tags', 'created', 'last_updated',
+ )
+ default_columns = (
+ 'pk', 'name', 'authentication_method', 'encryption_algorithm', 'authentication_algorithm', 'group',
+ 'sa_lifetime', 'description',
+ )
+
+
+class IKEPolicyTable(NetBoxTable):
+ name = tables.Column(
+ verbose_name=_('Name'),
+ linkify=True
+ )
+ version = tables.Column(
+ verbose_name=_('Version')
+ )
+ mode = tables.Column(
+ verbose_name=_('Mode')
+ )
+ proposals = tables.ManyToManyColumn(
+ linkify_item=True,
+ verbose_name=_('Proposals')
+ )
+ preshared_key = tables.Column(
+ verbose_name=_('Pre-shared Key')
+ )
+ comments = columns.MarkdownColumn(
+ verbose_name=_('Comments'),
+ )
+ tags = columns.TagColumn(
+ url_name='vpn:ikepolicy_list'
+ )
+
+ class Meta(NetBoxTable.Meta):
+ model = IKEPolicy
+ fields = (
+ 'pk', 'id', 'name', 'version', 'mode', 'proposals', 'preshared_key', 'description', 'comments', 'tags',
+ 'created', 'last_updated',
+ )
+ default_columns = (
+ 'pk', 'name', 'version', 'mode', 'proposals', 'description',
+ )
+
+
+class IPSecProposalTable(NetBoxTable):
+ name = tables.Column(
+ verbose_name=_('Name'),
+ linkify=True
+ )
+ encryption_algorithm = tables.Column(
+ verbose_name=_('Encryption Algorithm')
+ )
+ authentication_algorithm = tables.Column(
+ verbose_name=_('Authentication Algorithm')
+ )
+ sa_lifetime_seconds = tables.Column(
+ verbose_name=_('SA Lifetime (Seconds)')
+ )
+ sa_lifetime_data = tables.Column(
+ verbose_name=_('SA Lifetime (KB)')
+ )
+ comments = columns.MarkdownColumn(
+ verbose_name=_('Comments'),
+ )
+ tags = columns.TagColumn(
+ url_name='vpn:ipsecproposal_list'
+ )
+
+ class Meta(NetBoxTable.Meta):
+ model = IPSecProposal
+ fields = (
+ 'pk', 'id', 'name', 'encryption_algorithm', 'authentication_algorithm', 'sa_lifetime_seconds',
+ 'sa_lifetime_data', 'description', 'comments', 'tags', 'created', 'last_updated',
+ )
+ default_columns = (
+ 'pk', 'name', 'encryption_algorithm', 'authentication_algorithm', 'sa_lifetime_seconds',
+ 'sa_lifetime_data', 'description',
+ )
+
+
+class IPSecPolicyTable(NetBoxTable):
+ name = tables.Column(
+ verbose_name=_('Name'),
+ linkify=True
+ )
+ proposals = tables.ManyToManyColumn(
+ linkify_item=True,
+ verbose_name=_('Proposals')
+ )
+ pfs_group = tables.Column(
+ verbose_name=_('PFS Group')
+ )
+ comments = columns.MarkdownColumn(
+ verbose_name=_('Comments'),
+ )
+ tags = columns.TagColumn(
+ url_name='vpn:ipsecpolicy_list'
+ )
+
+ class Meta(NetBoxTable.Meta):
+ model = IPSecPolicy
+ fields = (
+ 'pk', 'id', 'name', 'proposals', 'pfs_group', 'description', 'comments', 'tags', 'created', 'last_updated',
+ )
+ default_columns = (
+ 'pk', 'name', 'proposals', 'pfs_group', 'description',
+ )
+
+
+class IPSecProfileTable(NetBoxTable):
+ name = tables.Column(
+ verbose_name=_('Name'),
+ linkify=True
+ )
+ mode = tables.Column(
+ verbose_name=_('Mode')
+ )
+ ike_policy = tables.Column(
+ linkify=True,
+ verbose_name=_('IKE Policy')
+ )
+ ipsec_policy = tables.Column(
+ linkify=True,
+ verbose_name=_('IPSec Policy')
+ )
+ comments = columns.MarkdownColumn(
+ verbose_name=_('Comments'),
+ )
+ tags = columns.TagColumn(
+ url_name='vpn:ipsecprofile_list'
+ )
+
+ class Meta(NetBoxTable.Meta):
+ model = IPSecProfile
+ fields = (
+ 'pk', 'id', 'name', 'mode', 'ike_policy', 'ipsec_policy', 'description', 'comments', 'tags', 'created',
+ 'last_updated',
+ )
+ default_columns = ('pk', 'name', 'mode', 'ike_policy', 'ipsec_policy', 'description')
diff --git a/netbox/ipam/tables/l2vpn.py b/netbox/vpn/tables/l2vpn.py
similarity index 92%
rename from netbox/ipam/tables/l2vpn.py
rename to netbox/vpn/tables/l2vpn.py
index 8635ab62a..91fddbd66 100644
--- a/netbox/ipam/tables/l2vpn.py
+++ b/netbox/vpn/tables/l2vpn.py
@@ -1,9 +1,9 @@
-from django.utils.translation import gettext_lazy as _
import django_tables2 as tables
+from django.utils.translation import gettext_lazy as _
-from ipam.models import L2VPN, L2VPNTermination
from netbox.tables import NetBoxTable, columns
from tenancy.tables import TenancyColumnsMixin
+from vpn.models import L2VPN, L2VPNTermination
__all__ = (
'L2VPNTable',
@@ -37,7 +37,7 @@ class L2VPNTable(TenancyColumnsMixin, NetBoxTable):
verbose_name=_('Comments'),
)
tags = columns.TagColumn(
- url_name='ipam:l2vpn_list'
+ url_name='vpn:l2vpn_list'
)
class Meta(NetBoxTable.Meta):
@@ -73,12 +73,15 @@ class L2VPNTerminationTable(NetBoxTable):
orderable=False,
verbose_name=_('Object Site')
)
+ tags = columns.TagColumn(
+ url_name='ipam:l2vpntermination_list'
+ )
class Meta(NetBoxTable.Meta):
model = L2VPNTermination
fields = (
'pk', 'l2vpn', 'assigned_object_type', 'assigned_object', 'assigned_object_parent', 'assigned_object_site',
- 'actions',
+ 'tags', 'actions',
)
default_columns = (
'pk', 'l2vpn', 'assigned_object_type', 'assigned_object_parent', 'assigned_object', 'actions',
diff --git a/netbox/vpn/tables/tunnels.py b/netbox/vpn/tables/tunnels.py
new file mode 100644
index 000000000..c10985733
--- /dev/null
+++ b/netbox/vpn/tables/tunnels.py
@@ -0,0 +1,112 @@
+import django_tables2 as tables
+from django.utils.translation import gettext_lazy as _
+from django_tables2.utils import Accessor
+
+from netbox.tables import NetBoxTable, columns
+from tenancy.tables import TenancyColumnsMixin
+from vpn.models import *
+
+__all__ = (
+ 'TunnelTable',
+ 'TunnelGroupTable',
+ 'TunnelTerminationTable',
+)
+
+
+class TunnelGroupTable(NetBoxTable):
+ name = tables.Column(
+ verbose_name=_('Name'),
+ linkify=True
+ )
+ tunnel_count = columns.LinkedCountColumn(
+ viewname='vpn:tunnel_list',
+ url_params={'group_id': 'pk'},
+ verbose_name=_('Tunnels')
+ )
+ tags = columns.TagColumn(
+ url_name='vpn:tunnelgroup_list'
+ )
+
+ class Meta(NetBoxTable.Meta):
+ model = TunnelGroup
+ fields = (
+ 'pk', 'id', 'name', 'tunnel_count', 'description', 'slug', 'tags', 'actions', 'created', 'last_updated',
+ )
+ default_columns = ('pk', 'name', 'tunnel_count', 'description')
+
+
+class TunnelTable(TenancyColumnsMixin, NetBoxTable):
+ name = tables.Column(
+ verbose_name=_('Name'),
+ linkify=True
+ )
+ status = columns.ChoiceFieldColumn(
+ verbose_name=_('Status')
+ )
+ ipsec_profile = tables.Column(
+ verbose_name=_('IPSec profile'),
+ linkify=True
+ )
+ terminations_count = columns.LinkedCountColumn(
+ accessor=Accessor('count_terminations'),
+ viewname='vpn:tunneltermination_list',
+ url_params={'tunnel_id': 'pk'},
+ verbose_name=_('Terminations')
+ )
+ comments = columns.MarkdownColumn(
+ verbose_name=_('Comments'),
+ )
+ tags = columns.TagColumn(
+ url_name='vpn:tunnel_list'
+ )
+
+ class Meta(NetBoxTable.Meta):
+ model = Tunnel
+ fields = (
+ 'pk', 'id', 'name', 'status', 'encapsulation', 'ipsec_profile', 'tenant', 'tenant_group', 'tunnel_id',
+ 'termination_count', 'description', 'comments', 'tags', 'created', 'last_updated',
+ )
+ default_columns = ('pk', 'name', 'status', 'encapsulation', 'tenant', 'terminations_count')
+
+
+class TunnelTerminationTable(TenancyColumnsMixin, NetBoxTable):
+ tunnel = tables.Column(
+ verbose_name=_('Tunnel'),
+ linkify=True
+ )
+ role = columns.ChoiceFieldColumn(
+ verbose_name=_('Role')
+ )
+ termination_parent = tables.Column(
+ accessor='termination__parent_object',
+ linkify=True,
+ orderable=False,
+ verbose_name=_('Host')
+ )
+ termination = tables.Column(
+ verbose_name=_('Interface'),
+ linkify=True
+ )
+ ip_addresses = tables.ManyToManyColumn(
+ accessor=tables.A('termination__ip_addresses'),
+ orderable=False,
+ linkify_item=True,
+ verbose_name=_('IP Addresses')
+ )
+ outside_ip = tables.Column(
+ verbose_name=_('Outside IP'),
+ linkify=True
+ )
+ tags = columns.TagColumn(
+ url_name='vpn:tunneltermination_list'
+ )
+
+ class Meta(NetBoxTable.Meta):
+ model = TunnelTermination
+ fields = (
+ 'pk', 'id', 'tunnel', 'role', 'termination_parent', 'termination', 'ip_addresses', 'outside_ip', 'tags',
+ 'created', 'last_updated',
+ )
+ default_columns = (
+ 'pk', 'id', 'tunnel', 'role', 'termination_parent', 'termination', 'ip_addresses', 'outside_ip',
+ )
diff --git a/netbox/vpn/tests/__init__.py b/netbox/vpn/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/netbox/vpn/tests/test_api.py b/netbox/vpn/tests/test_api.py
new file mode 100644
index 000000000..eb0520c8b
--- /dev/null
+++ b/netbox/vpn/tests/test_api.py
@@ -0,0 +1,611 @@
+from django.urls import reverse
+
+from dcim.choices import InterfaceTypeChoices
+from dcim.models import Interface
+from ipam.models import VLAN
+from utilities.testing import APITestCase, APIViewTestCases, create_test_device
+from vpn.choices import *
+from vpn.models import *
+
+
+class AppTest(APITestCase):
+
+ def test_root(self):
+ url = reverse('vpn-api:api-root')
+ response = self.client.get('{}?format=api'.format(url), **self.header)
+
+ self.assertEqual(response.status_code, 200)
+
+
+class TunnelGroupTest(APIViewTestCases.APIViewTestCase):
+ model = TunnelGroup
+ brief_fields = ['display', 'id', 'name', 'slug', 'tunnel_count', 'url']
+ create_data = (
+ {
+ 'name': 'Tunnel Group 4',
+ 'slug': 'tunnel-group-4',
+ },
+ {
+ 'name': 'Tunnel Group 5',
+ 'slug': 'tunnel-group-5',
+ },
+ {
+ 'name': 'Tunnel Group 6',
+ 'slug': 'tunnel-group-6',
+ },
+ )
+ bulk_update_data = {
+ 'description': 'New description',
+ }
+
+ @classmethod
+ def setUpTestData(cls):
+
+ tunnel_groups = (
+ TunnelGroup(name='Tunnel Group 1', slug='tunnel-group-1'),
+ TunnelGroup(name='Tunnel Group 2', slug='tunnel-group-2'),
+ TunnelGroup(name='Tunnel Group 3', slug='tunnel-group-3'),
+ )
+ TunnelGroup.objects.bulk_create(tunnel_groups)
+
+
+class TunnelTest(APIViewTestCases.APIViewTestCase):
+ model = Tunnel
+ brief_fields = ['display', 'id', 'name', 'url']
+ bulk_update_data = {
+ 'status': TunnelStatusChoices.STATUS_PLANNED,
+ 'encapsulation': TunnelEncapsulationChoices.ENCAP_GRE,
+ 'description': 'New description',
+ }
+
+ @classmethod
+ def setUpTestData(cls):
+
+ tunnel_groups = (
+ TunnelGroup(name='Tunnel Group 1', slug='tunnel-group-1'),
+ TunnelGroup(name='Tunnel Group 2', slug='tunnel-group-2'),
+ )
+ TunnelGroup.objects.bulk_create(tunnel_groups)
+
+ tunnels = (
+ Tunnel(
+ name='Tunnel 1',
+ status=TunnelStatusChoices.STATUS_ACTIVE,
+ group=tunnel_groups[0],
+ encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP
+ ),
+ Tunnel(
+ name='Tunnel 2',
+ status=TunnelStatusChoices.STATUS_ACTIVE,
+ group=tunnel_groups[0],
+ encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP
+ ),
+ Tunnel(
+ name='Tunnel 3',
+ status=TunnelStatusChoices.STATUS_ACTIVE,
+ group=tunnel_groups[0],
+ encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP
+ ),
+ )
+ Tunnel.objects.bulk_create(tunnels)
+
+ cls.create_data = [
+ {
+ 'name': 'Tunnel 4',
+ 'status': TunnelStatusChoices.STATUS_DISABLED,
+ 'group': tunnel_groups[1].pk,
+ 'encapsulation': TunnelEncapsulationChoices.ENCAP_GRE,
+ },
+ {
+ 'name': 'Tunnel 5',
+ 'status': TunnelStatusChoices.STATUS_DISABLED,
+ 'group': tunnel_groups[1].pk,
+ 'encapsulation': TunnelEncapsulationChoices.ENCAP_GRE,
+ },
+ {
+ 'name': 'Tunnel 6',
+ 'status': TunnelStatusChoices.STATUS_DISABLED,
+ 'group': tunnel_groups[1].pk,
+ 'encapsulation': TunnelEncapsulationChoices.ENCAP_GRE,
+ },
+ ]
+
+
+class TunnelTerminationTest(APIViewTestCases.APIViewTestCase):
+ model = TunnelTermination
+ brief_fields = ['display', 'id', 'url']
+ bulk_update_data = {
+ 'role': TunnelTerminationRoleChoices.ROLE_PEER,
+ }
+
+ @classmethod
+ def setUpTestData(cls):
+ device = create_test_device('Device 1')
+ interfaces = (
+ Interface(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+ Interface(device=device, name='Interface 2', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+ Interface(device=device, name='Interface 3', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+ Interface(device=device, name='Interface 4', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+ Interface(device=device, name='Interface 5', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+ Interface(device=device, name='Interface 6', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+ )
+ Interface.objects.bulk_create(interfaces)
+
+ tunnel = Tunnel.objects.create(
+ name='Tunnel 1',
+ status=TunnelStatusChoices.STATUS_ACTIVE,
+ encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP
+ )
+
+ tunnel_terminations = (
+ TunnelTermination(
+ tunnel=tunnel,
+ role=TunnelTerminationRoleChoices.ROLE_HUB,
+ termination=interfaces[0]
+ ),
+ TunnelTermination(
+ tunnel=tunnel,
+ role=TunnelTerminationRoleChoices.ROLE_HUB,
+ termination=interfaces[1]
+ ),
+ TunnelTermination(
+ tunnel=tunnel,
+ role=TunnelTerminationRoleChoices.ROLE_HUB,
+ termination=interfaces[2]
+ ),
+ )
+ TunnelTermination.objects.bulk_create(tunnel_terminations)
+
+ cls.create_data = [
+ {
+ 'tunnel': tunnel.pk,
+ 'role': TunnelTerminationRoleChoices.ROLE_PEER,
+ 'termination_type': 'dcim.interface',
+ 'termination_id': interfaces[3].pk,
+ },
+ {
+ 'tunnel': tunnel.pk,
+ 'role': TunnelTerminationRoleChoices.ROLE_PEER,
+ 'termination_type': 'dcim.interface',
+ 'termination_id': interfaces[4].pk,
+ },
+ {
+ 'tunnel': tunnel.pk,
+ 'role': TunnelTerminationRoleChoices.ROLE_PEER,
+ 'termination_type': 'dcim.interface',
+ 'termination_id': interfaces[5].pk,
+ },
+ ]
+
+
+class IKEProposalTest(APIViewTestCases.APIViewTestCase):
+ model = IKEProposal
+ brief_fields = ['display', 'id', 'name', 'url']
+ bulk_update_data = {
+ 'authentication_method': AuthenticationMethodChoices.CERTIFICATES,
+ 'encryption_algorithm': EncryptionAlgorithmChoices.ENCRYPTION_AES192_CBC,
+ 'authentication_algorithm': AuthenticationAlgorithmChoices.AUTH_HMAC_MD5,
+ 'group': DHGroupChoices.GROUP_19,
+ 'description': 'New description',
+ }
+
+ @classmethod
+ def setUpTestData(cls):
+
+ ike_proposals = (
+ IKEProposal(
+ name='IKE Proposal 1',
+ authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+ group=DHGroupChoices.GROUP_14
+ ),
+ IKEProposal(
+ name='IKE Proposal 2',
+ authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+ group=DHGroupChoices.GROUP_14
+ ),
+ IKEProposal(
+ name='IKE Proposal 3',
+ authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+ group=DHGroupChoices.GROUP_14
+ ),
+ )
+ IKEProposal.objects.bulk_create(ike_proposals)
+
+ cls.create_data = [
+ {
+ 'name': 'IKE Proposal 4',
+ 'authentication_method': AuthenticationMethodChoices.CERTIFICATES,
+ 'encryption_algorithm': EncryptionAlgorithmChoices.ENCRYPTION_AES256_CBC,
+ 'authentication_algorithm': AuthenticationAlgorithmChoices.AUTH_HMAC_SHA256,
+ 'group': DHGroupChoices.GROUP_19,
+ },
+ {
+ 'name': 'IKE Proposal 5',
+ 'authentication_method': AuthenticationMethodChoices.CERTIFICATES,
+ 'encryption_algorithm': EncryptionAlgorithmChoices.ENCRYPTION_AES256_CBC,
+ 'authentication_algorithm': AuthenticationAlgorithmChoices.AUTH_HMAC_SHA256,
+ 'group': DHGroupChoices.GROUP_19,
+ },
+ {
+ 'name': 'IKE Proposal 6',
+ 'authentication_method': AuthenticationMethodChoices.CERTIFICATES,
+ 'encryption_algorithm': EncryptionAlgorithmChoices.ENCRYPTION_AES256_CBC,
+ 'authentication_algorithm': AuthenticationAlgorithmChoices.AUTH_HMAC_SHA256,
+ 'group': DHGroupChoices.GROUP_19,
+ },
+ ]
+
+
+class IKEPolicyTest(APIViewTestCases.APIViewTestCase):
+ model = IKEPolicy
+ brief_fields = ['display', 'id', 'name', 'url']
+ bulk_update_data = {
+ 'version': IKEVersionChoices.VERSION_1,
+ 'mode': IKEModeChoices.AGGRESSIVE,
+ 'description': 'New description',
+ 'preshared_key': 'New key',
+ }
+
+ @classmethod
+ def setUpTestData(cls):
+
+ ike_proposals = (
+ IKEProposal(
+ name='IKE Proposal 1',
+ authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+ group=DHGroupChoices.GROUP_14
+ ),
+ IKEProposal(
+ name='IKE Proposal 2',
+ authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+ group=DHGroupChoices.GROUP_14
+ ),
+ )
+ IKEProposal.objects.bulk_create(ike_proposals)
+
+ ike_policies = (
+ IKEPolicy(
+ name='IKE Policy 1',
+ version=IKEVersionChoices.VERSION_1,
+ mode=IKEModeChoices.MAIN,
+ ),
+ IKEPolicy(
+ name='IKE Policy 2',
+ version=IKEVersionChoices.VERSION_1,
+ mode=IKEModeChoices.MAIN,
+ ),
+ IKEPolicy(
+ name='IKE Policy 3',
+ version=IKEVersionChoices.VERSION_1,
+ mode=IKEModeChoices.MAIN,
+ ),
+ )
+ IKEPolicy.objects.bulk_create(ike_policies)
+ for ike_policy in ike_policies:
+ ike_policy.proposals.set(ike_proposals)
+
+ cls.create_data = [
+ {
+ 'name': 'IKE Policy 4',
+ 'version': IKEVersionChoices.VERSION_1,
+ 'mode': IKEModeChoices.MAIN,
+ 'proposals': [ike_proposals[0].pk, ike_proposals[1].pk],
+ },
+ {
+ 'name': 'IKE Policy 5',
+ 'version': IKEVersionChoices.VERSION_1,
+ 'mode': IKEModeChoices.MAIN,
+ 'proposals': [ike_proposals[0].pk, ike_proposals[1].pk],
+ },
+ {
+ 'name': 'IKE Policy 6',
+ 'version': IKEVersionChoices.VERSION_1,
+ 'mode': IKEModeChoices.MAIN,
+ 'proposals': [ike_proposals[0].pk, ike_proposals[1].pk],
+ },
+ ]
+
+
+class IPSecProposalTest(APIViewTestCases.APIViewTestCase):
+ model = IPSecProposal
+ brief_fields = ['display', 'id', 'name', 'url']
+ bulk_update_data = {
+ 'encryption_algorithm': EncryptionAlgorithmChoices.ENCRYPTION_AES192_CBC,
+ 'authentication_algorithm': AuthenticationAlgorithmChoices.AUTH_HMAC_MD5,
+ 'description': 'New description',
+ }
+
+ @classmethod
+ def setUpTestData(cls):
+
+ ipsec_proposals = (
+ IPSecProposal(
+ name='IPSec Proposal 1',
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1
+ ),
+ IPSecProposal(
+ name='IPSec Proposal 2',
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1
+ ),
+ IPSecProposal(
+ name='IPSec Proposal 3',
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1
+ ),
+ )
+ IPSecProposal.objects.bulk_create(ipsec_proposals)
+
+ cls.create_data = [
+ {
+ 'name': 'IPSec Proposal 4',
+ 'encryption_algorithm': EncryptionAlgorithmChoices.ENCRYPTION_AES256_CBC,
+ 'authentication_algorithm': AuthenticationAlgorithmChoices.AUTH_HMAC_SHA256,
+ },
+ {
+ 'name': 'IPSec Proposal 5',
+ 'encryption_algorithm': EncryptionAlgorithmChoices.ENCRYPTION_AES256_CBC,
+ 'authentication_algorithm': AuthenticationAlgorithmChoices.AUTH_HMAC_SHA256,
+ },
+ {
+ 'name': 'IPSec Proposal 6',
+ 'encryption_algorithm': EncryptionAlgorithmChoices.ENCRYPTION_AES256_CBC,
+ 'authentication_algorithm': AuthenticationAlgorithmChoices.AUTH_HMAC_SHA256,
+ },
+ ]
+
+
+class IPSecPolicyTest(APIViewTestCases.APIViewTestCase):
+ model = IPSecPolicy
+ brief_fields = ['display', 'id', 'name', 'url']
+ bulk_update_data = {
+ 'pfs_group': DHGroupChoices.GROUP_5,
+ 'description': 'New description',
+ }
+
+ @classmethod
+ def setUpTestData(cls):
+
+ ipsec_proposals = (
+ IPSecProposal(
+ name='IPSec Policy 1',
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1
+ ),
+ IPSecProposal(
+ name='IPSec Proposal 2',
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1
+ ),
+ )
+ IPSecProposal.objects.bulk_create(ipsec_proposals)
+
+ ipsec_policies = (
+ IPSecPolicy(
+ name='IPSec Policy 1',
+ pfs_group=DHGroupChoices.GROUP_14
+ ),
+ IPSecPolicy(
+ name='IPSec Policy 2',
+ pfs_group=DHGroupChoices.GROUP_14
+ ),
+ IPSecPolicy(
+ name='IPSec Policy 3',
+ pfs_group=DHGroupChoices.GROUP_14
+ ),
+ )
+ IPSecPolicy.objects.bulk_create(ipsec_policies)
+ for ipsec_policy in ipsec_policies:
+ ipsec_policy.proposals.set(ipsec_proposals)
+
+ cls.create_data = [
+ {
+ 'name': 'IPSec Policy 4',
+ 'pfs_group': DHGroupChoices.GROUP_16,
+ 'proposals': [ipsec_proposals[0].pk, ipsec_proposals[1].pk],
+ },
+ {
+ 'name': 'IPSec Policy 5',
+ 'pfs_group': DHGroupChoices.GROUP_16,
+ 'proposals': [ipsec_proposals[0].pk, ipsec_proposals[1].pk],
+ },
+ {
+ 'name': 'IPSec Policy 6',
+ 'pfs_group': DHGroupChoices.GROUP_16,
+ 'proposals': [ipsec_proposals[0].pk, ipsec_proposals[1].pk],
+ },
+ ]
+
+
+class IPSecProfileTest(APIViewTestCases.APIViewTestCase):
+ model = IPSecProfile
+ brief_fields = ['display', 'id', 'name', 'url']
+
+ @classmethod
+ def setUpTestData(cls):
+
+ ike_proposal = IKEProposal.objects.create(
+ name='IKE Proposal 1',
+ authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+ group=DHGroupChoices.GROUP_14
+ )
+
+ ipsec_proposal = IPSecProposal.objects.create(
+ name='IPSec Proposal 1',
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1
+ )
+
+ ike_policies = (
+ IKEPolicy(
+ name='IKE Policy 1',
+ version=IKEVersionChoices.VERSION_1,
+ mode=IKEModeChoices.MAIN,
+ ),
+ IKEPolicy(
+ name='IKE Policy 2',
+ version=IKEVersionChoices.VERSION_1,
+ mode=IKEModeChoices.MAIN,
+ ),
+ )
+ IKEPolicy.objects.bulk_create(ike_policies)
+ for ike_policy in ike_policies:
+ ike_policy.proposals.add(ike_proposal)
+
+ ipsec_policies = (
+ IPSecPolicy(
+ name='IPSec Policy 1',
+ pfs_group=DHGroupChoices.GROUP_14
+ ),
+ IPSecPolicy(
+ name='IPSec Policy 2',
+ pfs_group=DHGroupChoices.GROUP_14
+ ),
+ )
+ IPSecPolicy.objects.bulk_create(ipsec_policies)
+ for ipsec_policy in ipsec_policies:
+ ipsec_policy.proposals.add(ipsec_proposal)
+
+ ipsec_profiles = (
+ IPSecProfile(
+ name='IPSec Profile 1',
+ mode=IPSecModeChoices.ESP,
+ ike_policy=ike_policies[0],
+ ipsec_policy=ipsec_policies[0]
+ ),
+ IPSecProfile(
+ name='IPSec Profile 2',
+ mode=IPSecModeChoices.ESP,
+ ike_policy=ike_policies[0],
+ ipsec_policy=ipsec_policies[0]
+ ),
+ IPSecProfile(
+ name='IPSec Profile 3',
+ mode=IPSecModeChoices.ESP,
+ ike_policy=ike_policies[0],
+ ipsec_policy=ipsec_policies[0]
+ ),
+ )
+ IPSecProfile.objects.bulk_create(ipsec_profiles)
+
+ cls.create_data = [
+ {
+ 'name': 'IPSec Profile 4',
+ 'mode': IPSecModeChoices.AH,
+ 'ike_policy': ike_policies[1].pk,
+ 'ipsec_policy': ipsec_policies[1].pk,
+ },
+ ]
+
+ cls.bulk_update_data = {
+ 'mode': IPSecModeChoices.AH,
+ 'ike_policy': ike_policies[1].pk,
+ 'ipsec_policy': ipsec_policies[1].pk,
+ 'description': 'New description',
+ }
+
+
+class L2VPNTest(APIViewTestCases.APIViewTestCase):
+ model = L2VPN
+ brief_fields = ['display', 'id', 'identifier', 'name', 'slug', 'type', 'url']
+ create_data = [
+ {
+ 'name': 'L2VPN 4',
+ 'slug': 'l2vpn-4',
+ 'type': 'vxlan',
+ 'identifier': 33343344
+ },
+ {
+ 'name': 'L2VPN 5',
+ 'slug': 'l2vpn-5',
+ 'type': 'vxlan',
+ 'identifier': 33343345
+ },
+ {
+ 'name': 'L2VPN 6',
+ 'slug': 'l2vpn-6',
+ 'type': 'vpws',
+ 'identifier': 33343346
+ },
+ ]
+ bulk_update_data = {
+ 'description': 'New description',
+ }
+
+ @classmethod
+ def setUpTestData(cls):
+
+ l2vpns = (
+ L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=650001),
+ L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=650002),
+ L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'), # No RD
+ )
+ L2VPN.objects.bulk_create(l2vpns)
+
+
+class L2VPNTerminationTest(APIViewTestCases.APIViewTestCase):
+ model = L2VPNTermination
+ brief_fields = ['display', 'id', 'l2vpn', 'url']
+
+ @classmethod
+ def setUpTestData(cls):
+
+ vlans = (
+ VLAN(name='VLAN 1', vid=651),
+ VLAN(name='VLAN 2', vid=652),
+ VLAN(name='VLAN 3', vid=653),
+ VLAN(name='VLAN 4', vid=654),
+ VLAN(name='VLAN 5', vid=655),
+ VLAN(name='VLAN 6', vid=656),
+ VLAN(name='VLAN 7', vid=657)
+ )
+ VLAN.objects.bulk_create(vlans)
+
+ l2vpns = (
+ L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=650001),
+ L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=650002),
+ L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'), # No RD
+ )
+ L2VPN.objects.bulk_create(l2vpns)
+
+ l2vpnterminations = (
+ L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]),
+ L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[1]),
+ L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[2])
+ )
+ L2VPNTermination.objects.bulk_create(l2vpnterminations)
+
+ cls.create_data = [
+ {
+ 'l2vpn': l2vpns[0].pk,
+ 'assigned_object_type': 'ipam.vlan',
+ 'assigned_object_id': vlans[3].pk,
+ },
+ {
+ 'l2vpn': l2vpns[0].pk,
+ 'assigned_object_type': 'ipam.vlan',
+ 'assigned_object_id': vlans[4].pk,
+ },
+ {
+ 'l2vpn': l2vpns[0].pk,
+ 'assigned_object_type': 'ipam.vlan',
+ 'assigned_object_id': vlans[5].pk,
+ },
+ ]
+
+ cls.bulk_update_data = {
+ 'l2vpn': l2vpns[2].pk
+ }
diff --git a/netbox/vpn/tests/test_filtersets.py b/netbox/vpn/tests/test_filtersets.py
new file mode 100644
index 000000000..d4e80750d
--- /dev/null
+++ b/netbox/vpn/tests/test_filtersets.py
@@ -0,0 +1,891 @@
+from django.contrib.contenttypes.models import ContentType
+from django.test import TestCase
+
+from dcim.choices import InterfaceTypeChoices
+from dcim.models import Device, Interface, Site
+from ipam.models import IPAddress, VLAN, RouteTarget
+from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
+from virtualization.models import VirtualMachine, VMInterface
+from vpn.choices import *
+from vpn.filtersets import *
+from vpn.models import *
+
+
+class TunnelGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
+ queryset = TunnelGroup.objects.all()
+ filterset = TunnelGroupFilterSet
+
+ @classmethod
+ def setUpTestData(cls):
+
+ TunnelGroup.objects.bulk_create((
+ TunnelGroup(name='Tunnel Group 1', slug='tunnel-group-1', description='foobar1'),
+ TunnelGroup(name='Tunnel Group 2', slug='tunnel-group-2', description='foobar2'),
+ TunnelGroup(name='Tunnel Group 3', slug='tunnel-group-3'),
+ ))
+
+ def test_q(self):
+ params = {'q': 'foobar1'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+ def test_name(self):
+ params = {'name': ['Tunnel Group 1']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+ def test_slug(self):
+ params = {'slug': ['tunnel-group-1']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+ def test_description(self):
+ params = {'description': ['foobar1', 'foobar2']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
+class TunnelTestCase(TestCase, ChangeLoggedFilterSetTests):
+ queryset = Tunnel.objects.all()
+ filterset = TunnelFilterSet
+
+ @classmethod
+ def setUpTestData(cls):
+ ike_proposal = IKEProposal.objects.create(
+ name='IKE Proposal 1',
+ authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+ group=DHGroupChoices.GROUP_14
+ )
+ ike_policy = IKEPolicy.objects.create(
+ name='IKE Policy 1',
+ version=IKEVersionChoices.VERSION_1,
+ mode=IKEModeChoices.MAIN,
+ )
+ ike_policy.proposals.add(ike_proposal)
+ ipsec_proposal = IPSecProposal.objects.create(
+ name='IPSec Proposal 1',
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1
+ )
+ ipsec_policy = IPSecPolicy.objects.create(
+ name='IPSec Policy 1',
+ pfs_group=DHGroupChoices.GROUP_14
+ )
+ ipsec_policy.proposals.add(ipsec_proposal)
+ ipsec_profiles = (
+ IPSecProfile(
+ name='IPSec Profile 1',
+ mode=IPSecModeChoices.ESP,
+ ike_policy=ike_policy,
+ ipsec_policy=ipsec_policy
+ ),
+ IPSecProfile(
+ name='IPSec Profile 2',
+ mode=IPSecModeChoices.ESP,
+ ike_policy=ike_policy,
+ ipsec_policy=ipsec_policy
+ ),
+ )
+ IPSecProfile.objects.bulk_create(ipsec_profiles)
+
+ tunnel_groups = (
+ TunnelGroup(name='Tunnel Group 1', slug='tunnel-group-1'),
+ TunnelGroup(name='Tunnel Group 2', slug='tunnel-group-2'),
+ TunnelGroup(name='Tunnel Group 3', slug='tunnel-group-3'),
+ )
+ TunnelGroup.objects.bulk_create(tunnel_groups)
+
+ tunnels = (
+ Tunnel(
+ name='Tunnel 1',
+ status=TunnelStatusChoices.STATUS_ACTIVE,
+ group=tunnel_groups[0],
+ encapsulation=TunnelEncapsulationChoices.ENCAP_GRE,
+ ipsec_profile=ipsec_profiles[0],
+ tunnel_id=100,
+ description='foobar1'
+ ),
+ Tunnel(
+ name='Tunnel 2',
+ status=TunnelStatusChoices.STATUS_PLANNED,
+ group=tunnel_groups[1],
+ encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP,
+ ipsec_profile=ipsec_profiles[0],
+ tunnel_id=200,
+ description='foobar2'
+ ),
+ Tunnel(
+ name='Tunnel 3',
+ status=TunnelStatusChoices.STATUS_DISABLED,
+ group=tunnel_groups[2],
+ encapsulation=TunnelEncapsulationChoices.ENCAP_IPSEC_TUNNEL,
+ ipsec_profile=None,
+ tunnel_id=300,
+ description='foobar3'
+ ),
+ )
+ Tunnel.objects.bulk_create(tunnels)
+
+ def test_q(self):
+ params = {'q': 'foobar1'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+ def test_name(self):
+ params = {'name': ['Tunnel 1', 'Tunnel 2']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_status(self):
+ params = {'status': [TunnelStatusChoices.STATUS_ACTIVE, TunnelStatusChoices.STATUS_PLANNED]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_group(self):
+ tunnel_groups = TunnelGroup.objects.all()[:2]
+ params = {'group_id': [tunnel_groups[0].pk, tunnel_groups[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'group': [tunnel_groups[0].slug, tunnel_groups[1].slug]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_encapsulation(self):
+ params = {'encapsulation': [TunnelEncapsulationChoices.ENCAP_GRE, TunnelEncapsulationChoices.ENCAP_IP_IP]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_ipsec_profile(self):
+ ipsec_profiles = IPSecProfile.objects.all()[:2]
+ params = {'ipsec_profile_id': [ipsec_profiles[0].pk, ipsec_profiles[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'ipsec_profile': [ipsec_profiles[0].name, ipsec_profiles[1].name]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_tunnel_id(self):
+ params = {'tunnel_id': [100, 200]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_description(self):
+ params = {'description': ['foobar1', 'foobar2']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
+class TunnelTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
+ queryset = TunnelTermination.objects.all()
+ filterset = TunnelTerminationFilterSet
+
+ @classmethod
+ def setUpTestData(cls):
+ device = create_test_device('Device 1')
+ interfaces = (
+ Interface(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+ Interface(device=device, name='Interface 2', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+ Interface(device=device, name='Interface 3', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+ )
+ Interface.objects.bulk_create(interfaces)
+
+ virtual_machine = create_test_virtualmachine('Virtual Machine 1')
+ vm_interfaces = (
+ VMInterface(virtual_machine=virtual_machine, name='Interface 1'),
+ VMInterface(virtual_machine=virtual_machine, name='Interface 2'),
+ VMInterface(virtual_machine=virtual_machine, name='Interface 3'),
+ )
+ VMInterface.objects.bulk_create(vm_interfaces)
+
+ ip_addresses = (
+ IPAddress(address='192.168.0.1/32'),
+ IPAddress(address='192.168.0.2/32'),
+ IPAddress(address='192.168.0.3/32'),
+ IPAddress(address='192.168.0.4/32'),
+ IPAddress(address='192.168.0.5/32'),
+ IPAddress(address='192.168.0.6/32'),
+ )
+ IPAddress.objects.bulk_create(ip_addresses)
+
+ tunnels = (
+ Tunnel(
+ name='Tunnel 1',
+ status=TunnelStatusChoices.STATUS_ACTIVE,
+ encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP
+ ),
+ Tunnel(
+ name='Tunnel 2',
+ status=TunnelStatusChoices.STATUS_ACTIVE,
+ encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP
+ ),
+ Tunnel(
+ name='Tunnel 3',
+ status=TunnelStatusChoices.STATUS_ACTIVE,
+ encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP
+ ),
+ )
+ Tunnel.objects.bulk_create(tunnels)
+
+ tunnel_terminations = (
+ # Tunnel 1
+ TunnelTermination(
+ tunnel=tunnels[0],
+ role=TunnelTerminationRoleChoices.ROLE_HUB,
+ termination=interfaces[0],
+ outside_ip=ip_addresses[0]
+ ),
+ TunnelTermination(
+ tunnel=tunnels[0],
+ role=TunnelTerminationRoleChoices.ROLE_SPOKE,
+ termination=vm_interfaces[0],
+ outside_ip=ip_addresses[1]
+ ),
+ # Tunnel 2
+ TunnelTermination(
+ tunnel=tunnels[1],
+ role=TunnelTerminationRoleChoices.ROLE_HUB,
+ termination=interfaces[1],
+ outside_ip=ip_addresses[2]
+ ),
+ TunnelTermination(
+ tunnel=tunnels[1],
+ role=TunnelTerminationRoleChoices.ROLE_SPOKE,
+ termination=vm_interfaces[1],
+ outside_ip=ip_addresses[3]
+ ),
+ # Tunnel 3
+ TunnelTermination(
+ tunnel=tunnels[2],
+ role=TunnelTerminationRoleChoices.ROLE_PEER,
+ termination=interfaces[2],
+ outside_ip=ip_addresses[4]
+ ),
+ TunnelTermination(
+ tunnel=tunnels[2],
+ role=TunnelTerminationRoleChoices.ROLE_PEER,
+ termination=vm_interfaces[2],
+ outside_ip=ip_addresses[5]
+ ),
+ )
+ TunnelTermination.objects.bulk_create(tunnel_terminations)
+
+ def test_tunnel(self):
+ tunnels = Tunnel.objects.all()[:2]
+ params = {'tunnel_id': [tunnels[0].pk, tunnels[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+ params = {'tunnel': [tunnels[0].name, tunnels[1].name]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+ def test_role(self):
+ params = {'role': [TunnelTerminationRoleChoices.ROLE_HUB, TunnelTerminationRoleChoices.ROLE_SPOKE]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+ def test_termination_type(self):
+ params = {'termination_type': 'dcim.interface'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+ params = {'termination_type': 'virtualization.vminterface'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
+ def test_interface(self):
+ interfaces = Interface.objects.all()[:2]
+ params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'interface': [interfaces[0].name, interfaces[1].name]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_vminterface(self):
+ vm_interfaces = VMInterface.objects.all()[:2]
+ params = {'vminterface_id': [vm_interfaces[0].pk, vm_interfaces[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'vminterface': [vm_interfaces[0].name, vm_interfaces[1].name]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_outside_ip(self):
+ ip_addresses = IPAddress.objects.all()[:2]
+ params = {'outside_ip_id': [ip_addresses[0].pk, ip_addresses[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
+class IKEProposalTestCase(TestCase, ChangeLoggedFilterSetTests):
+ queryset = IKEProposal.objects.all()
+ filterset = IKEProposalFilterSet
+
+ @classmethod
+ def setUpTestData(cls):
+ ike_proposals = (
+ IKEProposal(
+ name='IKE Proposal 1',
+ authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+ group=DHGroupChoices.GROUP_1,
+ sa_lifetime=1000,
+ description='foobar1'
+ ),
+ IKEProposal(
+ name='IKE Proposal 2',
+ authentication_method=AuthenticationMethodChoices.CERTIFICATES,
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES192_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA256,
+ group=DHGroupChoices.GROUP_2,
+ sa_lifetime=2000,
+ description='foobar2'
+ ),
+ IKEProposal(
+ name='IKE Proposal 3',
+ authentication_method=AuthenticationMethodChoices.RSA_SIGNATURES,
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES256_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA512,
+ group=DHGroupChoices.GROUP_5,
+ sa_lifetime=3000,
+ description='foobar3'
+ ),
+ )
+ IKEProposal.objects.bulk_create(ike_proposals)
+
+ def test_q(self):
+ params = {'q': 'foobar1'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+ def test_name(self):
+ params = {'name': ['IKE Proposal 1', 'IKE Proposal 2']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_description(self):
+ params = {'description': ['foobar1', 'foobar2']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_authentication_method(self):
+ params = {'authentication_method': [
+ AuthenticationMethodChoices.PRESHARED_KEYS, AuthenticationMethodChoices.CERTIFICATES
+ ]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_encryption_algorithm(self):
+ params = {'encryption_algorithm': [
+ EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC, EncryptionAlgorithmChoices.ENCRYPTION_AES192_CBC
+ ]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_authentication_algorithm(self):
+ params = {'authentication_algorithm': [
+ AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1, AuthenticationAlgorithmChoices.AUTH_HMAC_SHA256
+ ]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_group(self):
+ params = {'group': [DHGroupChoices.GROUP_1, DHGroupChoices.GROUP_2]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_sa_lifetime(self):
+ params = {'sa_lifetime': [1000, 2000]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
+class IKEPolicyTestCase(TestCase, ChangeLoggedFilterSetTests):
+ queryset = IKEPolicy.objects.all()
+ filterset = IKEPolicyFilterSet
+
+ @classmethod
+ def setUpTestData(cls):
+ ike_proposals = (
+ IKEProposal(
+ name='IKE Proposal 1',
+ authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+ group=DHGroupChoices.GROUP_14
+ ),
+ IKEProposal(
+ name='IKE Proposal 2',
+ authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+ group=DHGroupChoices.GROUP_14
+ ),
+ IKEProposal(
+ name='IKE Proposal 3',
+ authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+ group=DHGroupChoices.GROUP_14
+ ),
+ )
+ IKEProposal.objects.bulk_create(ike_proposals)
+
+ ike_policies = (
+ IKEPolicy(
+ name='IKE Policy 1',
+ version=IKEVersionChoices.VERSION_1,
+ mode=IKEModeChoices.MAIN,
+ description='foobar1'
+ ),
+ IKEPolicy(
+ name='IKE Policy 2',
+ version=IKEVersionChoices.VERSION_1,
+ mode=IKEModeChoices.MAIN,
+ description='foobar2'
+ ),
+ IKEPolicy(
+ name='IKE Policy 3',
+ version=IKEVersionChoices.VERSION_2,
+ mode=IKEModeChoices.AGGRESSIVE,
+ description='foobar3'
+ ),
+ )
+ IKEPolicy.objects.bulk_create(ike_policies)
+ ike_policies[0].proposals.add(ike_proposals[0])
+ ike_policies[1].proposals.add(ike_proposals[1])
+ ike_policies[2].proposals.add(ike_proposals[2])
+
+ def test_q(self):
+ params = {'q': 'foobar1'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+ def test_name(self):
+ params = {'name': ['IKE Policy 1', 'IKE Policy 2']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_description(self):
+ params = {'description': ['foobar1', 'foobar2']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_version(self):
+ params = {'version': [IKEVersionChoices.VERSION_1]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_mode(self):
+ params = {'mode': [IKEModeChoices.MAIN]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_proposal(self):
+ proposals = IKEProposal.objects.all()[:2]
+ params = {'proposal_id': [proposals[0].pk, proposals[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'proposal': [proposals[0].name, proposals[1].name]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
+class IPSecProposalTestCase(TestCase, ChangeLoggedFilterSetTests):
+ queryset = IPSecProposal.objects.all()
+ filterset = IPSecProposalFilterSet
+
+ @classmethod
+ def setUpTestData(cls):
+ ipsec_proposals = (
+ IPSecProposal(
+ name='IPSec Proposal 1',
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+ sa_lifetime_seconds=1000,
+ sa_lifetime_data=1000,
+ description='foobar1'
+ ),
+ IPSecProposal(
+ name='IPSec Proposal 2',
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES192_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA256,
+ sa_lifetime_seconds=2000,
+ sa_lifetime_data=2000,
+ description='foobar2'
+ ),
+ IPSecProposal(
+ name='IPSec Proposal 3',
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES256_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA512,
+ sa_lifetime_seconds=3000,
+ sa_lifetime_data=3000,
+ description='foobar3'
+ ),
+ )
+ IPSecProposal.objects.bulk_create(ipsec_proposals)
+
+ def test_q(self):
+ params = {'q': 'foobar1'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+ def test_name(self):
+ params = {'name': ['IPSec Proposal 1', 'IPSec Proposal 2']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_description(self):
+ params = {'description': ['foobar1', 'foobar2']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_encryption_algorithm(self):
+ params = {'encryption_algorithm': [
+ EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC, EncryptionAlgorithmChoices.ENCRYPTION_AES192_CBC
+ ]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_authentication_algorithm(self):
+ params = {'authentication_algorithm': [
+ AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1, AuthenticationAlgorithmChoices.AUTH_HMAC_SHA256
+ ]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_sa_lifetime_seconds(self):
+ params = {'sa_lifetime_seconds': [1000, 2000]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_sa_lifetime_data(self):
+ params = {'sa_lifetime_data': [1000, 2000]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
+class IPSecPolicyTestCase(TestCase, ChangeLoggedFilterSetTests):
+ queryset = IPSecPolicy.objects.all()
+ filterset = IPSecPolicyFilterSet
+
+ @classmethod
+ def setUpTestData(cls):
+ ipsec_proposals = (
+ IPSecProposal(
+ name='IPSec Policy 1',
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1
+ ),
+ IPSecProposal(
+ name='IPSec Proposal 2',
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1
+ ),
+ IPSecProposal(
+ name='IPSec Proposal 3',
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1
+ ),
+ )
+ IPSecProposal.objects.bulk_create(ipsec_proposals)
+
+ ipsec_policies = (
+ IPSecPolicy(
+ name='IPSec Policy 1',
+ pfs_group=DHGroupChoices.GROUP_1,
+ description='foobar1'
+ ),
+ IPSecPolicy(
+ name='IPSec Policy 2',
+ pfs_group=DHGroupChoices.GROUP_2,
+ description='foobar2'
+ ),
+ IPSecPolicy(
+ name='IPSec Policy 3',
+ pfs_group=DHGroupChoices.GROUP_5,
+ description='foobar3'
+ ),
+ )
+ IPSecPolicy.objects.bulk_create(ipsec_policies)
+ ipsec_policies[0].proposals.add(ipsec_proposals[0])
+ ipsec_policies[1].proposals.add(ipsec_proposals[1])
+ ipsec_policies[2].proposals.add(ipsec_proposals[2])
+
+ def test_q(self):
+ params = {'q': 'foobar1'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+ def test_name(self):
+ params = {'name': ['IPSec Policy 1', 'IPSec Policy 2']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_description(self):
+ params = {'description': ['foobar1', 'foobar2']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_pfs_group(self):
+ params = {'pfs_group': [DHGroupChoices.GROUP_1, DHGroupChoices.GROUP_2]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_proposal(self):
+ proposals = IPSecProposal.objects.all()[:2]
+ params = {'proposal_id': [proposals[0].pk, proposals[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'proposal': [proposals[0].name, proposals[1].name]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
+class IPSecProfileTestCase(TestCase, ChangeLoggedFilterSetTests):
+ queryset = IPSecProfile.objects.all()
+ filterset = IPSecProfileFilterSet
+
+ @classmethod
+ def setUpTestData(cls):
+ ike_proposal = IKEProposal.objects.create(
+ name='IKE Proposal 1',
+ authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+ group=DHGroupChoices.GROUP_14
+ )
+ ipsec_proposal = IPSecProposal.objects.create(
+ name='IPSec Proposal 1',
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1
+ )
+
+ ike_policies = (
+ IKEPolicy(
+ name='IKE Policy 1',
+ version=IKEVersionChoices.VERSION_1,
+ mode=IKEModeChoices.MAIN,
+ ),
+ IKEPolicy(
+ name='IKE Policy 2',
+ version=IKEVersionChoices.VERSION_1,
+ mode=IKEModeChoices.MAIN,
+ ),
+ IKEPolicy(
+ name='IKE Policy 3',
+ version=IKEVersionChoices.VERSION_1,
+ mode=IKEModeChoices.MAIN,
+ ),
+ )
+ IKEPolicy.objects.bulk_create(ike_policies)
+ for ike_policy in ike_policies:
+ ike_policy.proposals.add(ike_proposal)
+
+ ipsec_policies = (
+ IPSecPolicy(
+ name='IPSec Policy 1',
+ pfs_group=DHGroupChoices.GROUP_14
+ ),
+ IPSecPolicy(
+ name='IPSec Policy 2',
+ pfs_group=DHGroupChoices.GROUP_14
+ ),
+ IPSecPolicy(
+ name='IPSec Policy 3',
+ pfs_group=DHGroupChoices.GROUP_14
+ ),
+ )
+ IPSecPolicy.objects.bulk_create(ipsec_policies)
+ for ipsec_policy in ipsec_policies:
+ ipsec_policy.proposals.add(ipsec_proposal)
+
+ ipsec_profiles = (
+ IPSecProfile(
+ name='IPSec Profile 1',
+ mode=IPSecModeChoices.ESP,
+ ike_policy=ike_policies[0],
+ ipsec_policy=ipsec_policies[0],
+ description='foobar1'
+ ),
+ IPSecProfile(
+ name='IPSec Profile 2',
+ mode=IPSecModeChoices.ESP,
+ ike_policy=ike_policies[1],
+ ipsec_policy=ipsec_policies[1],
+ description='foobar2'
+ ),
+ IPSecProfile(
+ name='IPSec Profile 3',
+ mode=IPSecModeChoices.AH,
+ ike_policy=ike_policies[2],
+ ipsec_policy=ipsec_policies[2],
+ description='foobar3'
+ ),
+ )
+ IPSecProfile.objects.bulk_create(ipsec_profiles)
+
+ def test_q(self):
+ params = {'q': 'foobar1'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+ def test_name(self):
+ params = {'name': ['IPSec Profile 1', 'IPSec Profile 2']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_description(self):
+ params = {'description': ['foobar1', 'foobar2']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_mode(self):
+ params = {'mode': [IPSecModeChoices.ESP]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_ike_policy(self):
+ ike_policies = IKEPolicy.objects.all()[:2]
+ params = {'ike_policy_id': [ike_policies[0].pk, ike_policies[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'ike_policy': [ike_policies[0].name, ike_policies[1].name]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_ipsec_policy(self):
+ ipsec_policies = IPSecPolicy.objects.all()[:2]
+ params = {'ipsec_policy_id': [ipsec_policies[0].pk, ipsec_policies[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'ipsec_policy': [ipsec_policies[0].name, ipsec_policies[1].name]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
+class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests):
+ queryset = L2VPN.objects.all()
+ filterset = L2VPNFilterSet
+
+ @classmethod
+ def setUpTestData(cls):
+
+ route_targets = (
+ RouteTarget(name='1:1'),
+ RouteTarget(name='1:2'),
+ RouteTarget(name='1:3'),
+ RouteTarget(name='2:1'),
+ RouteTarget(name='2:2'),
+ RouteTarget(name='2:3'),
+ )
+ RouteTarget.objects.bulk_create(route_targets)
+
+ l2vpns = (
+ L2VPN(
+ name='L2VPN 1',
+ slug='l2vpn-1',
+ type=L2VPNTypeChoices.TYPE_VXLAN,
+ identifier=65001,
+ description='foobar1'
+ ),
+ L2VPN(
+ name='L2VPN 2',
+ slug='l2vpn-2',
+ type=L2VPNTypeChoices.TYPE_VPWS,
+ identifier=65002,
+ description='foobar2'
+ ),
+ L2VPN(
+ name='L2VPN 3',
+ slug='l2vpn-3',
+ type=L2VPNTypeChoices.TYPE_VPLS,
+ description='foobar3'
+ ),
+ )
+ L2VPN.objects.bulk_create(l2vpns)
+ l2vpns[0].import_targets.add(route_targets[0])
+ l2vpns[1].import_targets.add(route_targets[1])
+ l2vpns[2].import_targets.add(route_targets[2])
+ l2vpns[0].export_targets.add(route_targets[3])
+ l2vpns[1].export_targets.add(route_targets[4])
+ l2vpns[2].export_targets.add(route_targets[5])
+
+ def test_q(self):
+ params = {'q': 'foobar1'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+ def test_name(self):
+ params = {'name': ['L2VPN 1', 'L2VPN 2']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_slug(self):
+ params = {'slug': ['l2vpn-1', 'l2vpn-2']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_identifier(self):
+ params = {'identifier': ['65001', '65002']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_type(self):
+ params = {'type': [L2VPNTypeChoices.TYPE_VXLAN, L2VPNTypeChoices.TYPE_VPWS]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_description(self):
+ params = {'description': ['foobar1', 'foobar2']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_import_targets(self):
+ route_targets = RouteTarget.objects.filter(name__in=['1:1', '1:2'])
+ params = {'import_target_id': [route_targets[0].pk, route_targets[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'import_target': [route_targets[0].name, route_targets[1].name]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_export_targets(self):
+ route_targets = RouteTarget.objects.filter(name__in=['2:1', '2:2'])
+ params = {'export_target_id': [route_targets[0].pk, route_targets[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'export_target': [route_targets[0].name, route_targets[1].name]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
+class L2VPNTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
+ queryset = L2VPNTermination.objects.all()
+ filterset = L2VPNTerminationFilterSet
+
+ @classmethod
+ def setUpTestData(cls):
+ device = create_test_device('Device 1')
+ interfaces = (
+ Interface(name='Interface 1', device=device, type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+ Interface(name='Interface 2', device=device, type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+ Interface(name='Interface 3', device=device, type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+ )
+ Interface.objects.bulk_create(interfaces)
+
+ vm = create_test_virtualmachine('Virtual Machine 1')
+ vminterfaces = (
+ VMInterface(name='Interface 1', virtual_machine=vm),
+ VMInterface(name='Interface 2', virtual_machine=vm),
+ VMInterface(name='Interface 3', virtual_machine=vm),
+ )
+ VMInterface.objects.bulk_create(vminterfaces)
+
+ vlans = (
+ VLAN(name='VLAN 1', vid=101),
+ VLAN(name='VLAN 2', vid=102),
+ VLAN(name='VLAN 3', vid=103),
+ )
+ VLAN.objects.bulk_create(vlans)
+
+ l2vpns = (
+ L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=65001),
+ L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=65002),
+ L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'), # No RD,
+ )
+ L2VPN.objects.bulk_create(l2vpns)
+
+ l2vpnterminations = (
+ L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]),
+ L2VPNTermination(l2vpn=l2vpns[1], assigned_object=vlans[1]),
+ L2VPNTermination(l2vpn=l2vpns[2], assigned_object=vlans[2]),
+ L2VPNTermination(l2vpn=l2vpns[0], assigned_object=interfaces[0]),
+ L2VPNTermination(l2vpn=l2vpns[1], assigned_object=interfaces[1]),
+ L2VPNTermination(l2vpn=l2vpns[2], assigned_object=interfaces[2]),
+ L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vminterfaces[0]),
+ L2VPNTermination(l2vpn=l2vpns[1], assigned_object=vminterfaces[1]),
+ L2VPNTermination(l2vpn=l2vpns[2], assigned_object=vminterfaces[2]),
+ )
+ L2VPNTermination.objects.bulk_create(l2vpnterminations)
+
+ def test_l2vpn(self):
+ l2vpns = L2VPN.objects.all()[:2]
+ params = {'l2vpn_id': [l2vpns[0].pk, l2vpns[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
+ params = {'l2vpn': [l2vpns[0].slug, l2vpns[1].slug]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
+
+ def test_content_type(self):
+ params = {'assigned_object_type_id': ContentType.objects.get(model='vlan').pk}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
+ def test_interface(self):
+ interfaces = Interface.objects.all()[:2]
+ params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_vminterface(self):
+ vminterfaces = VMInterface.objects.all()[:2]
+ params = {'vminterface_id': [vminterfaces[0].pk, vminterfaces[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_vlan(self):
+ vlans = VLAN.objects.all()[:2]
+ params = {'vlan_id': [vlans[0].pk, vlans[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'vlan': ['VLAN 1', 'VLAN 2']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_site(self):
+ site = Site.objects.all().first()
+ params = {'site_id': [site.pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+ params = {'site': ['site-1']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
+ def test_device(self):
+ device = Device.objects.all().first()
+ params = {'device_id': [device.pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+ params = {'device': ['Device 1']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
+ def test_virtual_machine(self):
+ virtual_machine = VirtualMachine.objects.all().first()
+ params = {'virtual_machine_id': [virtual_machine.pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+ params = {'virtual_machine': ['Virtual Machine 1']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
diff --git a/netbox/vpn/tests/test_models.py b/netbox/vpn/tests/test_models.py
new file mode 100644
index 000000000..e464dccd9
--- /dev/null
+++ b/netbox/vpn/tests/test_models.py
@@ -0,0 +1,79 @@
+from django.core.exceptions import ValidationError
+from django.test import TestCase
+
+from dcim.models import Interface, Device, DeviceRole, DeviceType, Manufacturer, Site
+from ipam.models import VLAN
+from vpn.models import *
+
+
+class TestL2VPNTermination(TestCase):
+
+ @classmethod
+ def setUpTestData(cls):
+
+ site = Site.objects.create(name='Site 1')
+ manufacturer = Manufacturer.objects.create(name='Manufacturer 1')
+ device_type = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer)
+ role = DeviceRole.objects.create(name='Switch')
+ device = Device.objects.create(
+ name='Device 1',
+ site=site,
+ device_type=device_type,
+ role=role,
+ status='active'
+ )
+
+ interfaces = (
+ Interface(name='Interface 1', device=device, type='1000baset'),
+ Interface(name='Interface 2', device=device, type='1000baset'),
+ Interface(name='Interface 3', device=device, type='1000baset'),
+ Interface(name='Interface 4', device=device, type='1000baset'),
+ Interface(name='Interface 5', device=device, type='1000baset'),
+ )
+
+ Interface.objects.bulk_create(interfaces)
+
+ vlans = (
+ VLAN(name='VLAN 1', vid=651),
+ VLAN(name='VLAN 2', vid=652),
+ VLAN(name='VLAN 3', vid=653),
+ VLAN(name='VLAN 4', vid=654),
+ VLAN(name='VLAN 5', vid=655),
+ VLAN(name='VLAN 6', vid=656),
+ VLAN(name='VLAN 7', vid=657)
+ )
+
+ VLAN.objects.bulk_create(vlans)
+
+ l2vpns = (
+ L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=650001),
+ L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=650002),
+ L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'), # No RD
+ )
+ L2VPN.objects.bulk_create(l2vpns)
+
+ l2vpnterminations = (
+ L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]),
+ L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[1]),
+ L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[2])
+ )
+
+ L2VPNTermination.objects.bulk_create(l2vpnterminations)
+
+ def test_duplicate_interface_terminations(self):
+ device = Device.objects.first()
+ interface = Interface.objects.filter(device=device).first()
+ l2vpn = L2VPN.objects.first()
+
+ L2VPNTermination.objects.create(l2vpn=l2vpn, assigned_object=interface)
+ duplicate = L2VPNTermination(l2vpn=l2vpn, assigned_object=interface)
+
+ self.assertRaises(ValidationError, duplicate.clean)
+
+ def test_duplicate_vlan_terminations(self):
+ vlan = Interface.objects.first()
+ l2vpn = L2VPN.objects.first()
+
+ L2VPNTermination.objects.create(l2vpn=l2vpn, assigned_object=vlan)
+ duplicate = L2VPNTermination(l2vpn=l2vpn, assigned_object=vlan)
+ self.assertRaises(ValidationError, duplicate.clean)
diff --git a/netbox/vpn/tests/test_views.py b/netbox/vpn/tests/test_views.py
new file mode 100644
index 000000000..ab797d9fd
--- /dev/null
+++ b/netbox/vpn/tests/test_views.py
@@ -0,0 +1,702 @@
+from dcim.choices import InterfaceTypeChoices
+from dcim.models import Interface
+from ipam.models import RouteTarget, VLAN
+from utilities.testing import ViewTestCases, create_tags, create_test_device
+from vpn.choices import *
+from vpn.models import *
+
+
+class TunnelGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
+ model = TunnelGroup
+
+ @classmethod
+ def setUpTestData(cls):
+
+ tunnel_groups = (
+ TunnelGroup(name='Tunnel Group 1', slug='tunnel-group-1'),
+ TunnelGroup(name='Tunnel Group 2', slug='tunnel-group-2'),
+ TunnelGroup(name='Tunnel Group 3', slug='tunnel-group-3'),
+ )
+ TunnelGroup.objects.bulk_create(tunnel_groups)
+
+ tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+ cls.form_data = {
+ 'name': 'Tunnel Group X',
+ 'slug': 'tunnel-group-x',
+ 'description': 'A new Tunnel Group',
+ 'tags': [t.pk for t in tags],
+ }
+
+ cls.csv_data = (
+ "name,slug",
+ "Tunnel Group 4,tunnel-group-4",
+ "Tunnel Group 5,tunnel-group-5",
+ "Tunnel Group 6,tunnel-group-6",
+ )
+
+ cls.csv_update_data = (
+ "id,name,description",
+ f"{tunnel_groups[0].pk},Tunnel Group 7,New description7",
+ f"{tunnel_groups[1].pk},Tunnel Group 8,New description8",
+ f"{tunnel_groups[2].pk},Tunnel Group 9,New description9",
+ )
+
+ cls.bulk_edit_data = {
+ 'description': 'Foo',
+ }
+
+
+class TunnelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+ model = Tunnel
+
+ @classmethod
+ def setUpTestData(cls):
+
+ tunnel_groups = (
+ TunnelGroup(name='Tunnel Group 1', slug='tunnel-group-1'),
+ TunnelGroup(name='Tunnel Group 2', slug='tunnel-group-2'),
+ TunnelGroup(name='Tunnel Group 3', slug='tunnel-group-3'),
+ TunnelGroup(name='Tunnel Group 4', slug='tunnel-group-4'),
+ )
+ TunnelGroup.objects.bulk_create(tunnel_groups)
+
+ tunnels = (
+ Tunnel(
+ name='Tunnel 1',
+ status=TunnelStatusChoices.STATUS_ACTIVE,
+ group=tunnel_groups[0],
+ encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP
+ ),
+ Tunnel(
+ name='Tunnel 2',
+ status=TunnelStatusChoices.STATUS_ACTIVE,
+ group=tunnel_groups[1],
+ encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP
+ ),
+ Tunnel(
+ name='Tunnel 3',
+ status=TunnelStatusChoices.STATUS_ACTIVE,
+ group=tunnel_groups[2],
+ encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP
+ ),
+ )
+ Tunnel.objects.bulk_create(tunnels)
+
+ tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+ cls.form_data = {
+ 'name': 'Tunnel X',
+ 'description': 'New tunnel',
+ 'status': TunnelStatusChoices.STATUS_PLANNED,
+ 'group': tunnel_groups[3].pk,
+ 'encapsulation': TunnelEncapsulationChoices.ENCAP_GRE,
+ 'tags': [t.pk for t in tags],
+ }
+
+ cls.csv_data = (
+ "name,status,group,encapsulation",
+ "Tunnel 4,planned,Tunnel Group 1,gre",
+ "Tunnel 5,planned,Tunnel Group 2,gre",
+ "Tunnel 6,planned,Tunnel Group 3,gre",
+ )
+
+ cls.csv_update_data = (
+ "id,status,group,encapsulation",
+ f"{tunnels[0].pk},active,Tunnel Group 4,ip-ip",
+ f"{tunnels[1].pk},active,Tunnel Group 4,ip-ip",
+ f"{tunnels[2].pk},active,Tunnel Group 4,ip-ip",
+ )
+
+ cls.bulk_edit_data = {
+ 'description': 'New description',
+ 'group': tunnel_groups[3].pk,
+ 'status': TunnelStatusChoices.STATUS_DISABLED,
+ 'encapsulation': TunnelEncapsulationChoices.ENCAP_GRE,
+ }
+
+
+class TunnelTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+ model = TunnelTermination
+ # TODO: Workaround for conflict between form field and GFK
+ validation_excluded_fields = ('termination',)
+
+ @classmethod
+ def setUpTestData(cls):
+ device = create_test_device('Device 1')
+ interfaces = (
+ Interface(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+ Interface(device=device, name='Interface 2', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+ Interface(device=device, name='Interface 3', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+ Interface(device=device, name='Interface 4', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+ Interface(device=device, name='Interface 5', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+ Interface(device=device, name='Interface 6', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+ Interface(device=device, name='Interface 7', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+ )
+ Interface.objects.bulk_create(interfaces)
+
+ tunnel = Tunnel.objects.create(
+ name='Tunnel 1',
+ status=TunnelStatusChoices.STATUS_ACTIVE,
+ encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP
+ )
+
+ tunnel_terminations = (
+ TunnelTermination(
+ tunnel=tunnel,
+ role=TunnelTerminationRoleChoices.ROLE_HUB,
+ termination=interfaces[0]
+ ),
+ TunnelTermination(
+ tunnel=tunnel,
+ role=TunnelTerminationRoleChoices.ROLE_SPOKE,
+ termination=interfaces[1]
+ ),
+ TunnelTermination(
+ tunnel=tunnel,
+ role=TunnelTerminationRoleChoices.ROLE_SPOKE,
+ termination=interfaces[2]
+ ),
+ )
+ TunnelTermination.objects.bulk_create(tunnel_terminations)
+
+ tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+ cls.form_data = {
+ 'tunnel': tunnel.pk,
+ 'role': TunnelTerminationRoleChoices.ROLE_PEER,
+ 'type': TunnelTerminationTypeChoices.TYPE_DEVICE,
+ 'parent': device.pk,
+ 'termination': interfaces[6].pk,
+ 'tags': [t.pk for t in tags],
+ }
+
+ cls.csv_data = (
+ "tunnel,role,device,termination",
+ "Tunnel 1,peer,Device 1,Interface 4",
+ "Tunnel 1,peer,Device 1,Interface 5",
+ "Tunnel 1,peer,Device 1,Interface 6",
+ )
+
+ cls.csv_update_data = (
+ "id,role",
+ f"{tunnel_terminations[0].pk},peer",
+ f"{tunnel_terminations[1].pk},peer",
+ f"{tunnel_terminations[2].pk},peer",
+ )
+
+ cls.bulk_edit_data = {
+ 'role': TunnelTerminationRoleChoices.ROLE_PEER,
+ }
+
+
+class IKEProposalTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+ model = IKEProposal
+
+ @classmethod
+ def setUpTestData(cls):
+
+ ike_proposals = (
+ IKEProposal(
+ name='IKE Proposal 1',
+ authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+ group=DHGroupChoices.GROUP_14
+ ),
+ IKEProposal(
+ name='IKE Proposal 2',
+ authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+ group=DHGroupChoices.GROUP_14
+ ),
+ IKEProposal(
+ name='IKE Proposal 3',
+ authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+ group=DHGroupChoices.GROUP_14
+ ),
+ )
+ IKEProposal.objects.bulk_create(ike_proposals)
+
+ tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+ cls.form_data = {
+ 'name': 'IKE Proposal X',
+ 'authentication_method': AuthenticationMethodChoices.CERTIFICATES,
+ 'encryption_algorithm': EncryptionAlgorithmChoices.ENCRYPTION_AES192_CBC,
+ 'authentication_algorithm': AuthenticationAlgorithmChoices.AUTH_HMAC_SHA256,
+ 'group': DHGroupChoices.GROUP_19,
+ 'tags': [t.pk for t in tags],
+ }
+
+ cls.csv_data = (
+ "name,authentication_method,encryption_algorithm,authentication_algorithm,group",
+ "IKE Proposal 4,preshared-keys,aes-128-cbc,hmac-sha1,14",
+ "IKE Proposal 5,preshared-keys,aes-128-cbc,hmac-sha1,14",
+ "IKE Proposal 6,preshared-keys,aes-128-cbc,hmac-sha1,14",
+ )
+
+ cls.csv_update_data = (
+ "id,description",
+ f"{ike_proposals[0].pk},New description",
+ f"{ike_proposals[1].pk},New description",
+ f"{ike_proposals[2].pk},New description",
+ )
+
+ cls.bulk_edit_data = {
+ 'description': 'New description',
+ 'authentication_method': AuthenticationMethodChoices.CERTIFICATES,
+ 'encryption_algorithm': EncryptionAlgorithmChoices.ENCRYPTION_AES192_CBC,
+ 'authentication_algorithm': AuthenticationAlgorithmChoices.AUTH_HMAC_SHA256,
+ 'group': DHGroupChoices.GROUP_19
+ }
+
+
+class IKEPolicyTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+ model = IKEPolicy
+
+ @classmethod
+ def setUpTestData(cls):
+
+ ike_proposals = (
+ IKEProposal(
+ name='IKE Proposal 1',
+ authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+ group=DHGroupChoices.GROUP_14
+ ),
+ IKEProposal(
+ name='IKE Proposal 2',
+ authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+ group=DHGroupChoices.GROUP_14
+ ),
+ )
+ IKEProposal.objects.bulk_create(ike_proposals)
+
+ ike_policies = (
+ IKEPolicy(
+ name='IKE Policy 1',
+ version=IKEVersionChoices.VERSION_1,
+ mode=IKEModeChoices.MAIN,
+ ),
+ IKEPolicy(
+ name='IKE Policy 2',
+ version=IKEVersionChoices.VERSION_1,
+ mode=IKEModeChoices.MAIN,
+ ),
+ IKEPolicy(
+ name='IKE Policy 3',
+ version=IKEVersionChoices.VERSION_1,
+ mode=IKEModeChoices.MAIN,
+ ),
+ )
+ IKEPolicy.objects.bulk_create(ike_policies)
+ for ike_policy in ike_policies:
+ ike_policy.proposals.set(ike_proposals)
+
+ tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+ cls.form_data = {
+ 'name': 'IKE Policy X',
+ 'version': IKEVersionChoices.VERSION_2,
+ 'mode': IKEModeChoices.AGGRESSIVE,
+ 'proposals': [p.pk for p in ike_proposals],
+ 'tags': [t.pk for t in tags],
+ }
+
+ ike_proposal_names = ','.join([p.name for p in ike_proposals])
+ cls.csv_data = (
+ "name,version,mode,proposals",
+ f"IKE Proposal 4,2,aggressive,\"{ike_proposal_names}\"",
+ f"IKE Proposal 5,2,aggressive,\"{ike_proposal_names}\"",
+ f"IKE Proposal 6,2,aggressive,\"{ike_proposal_names}\"",
+ )
+
+ cls.csv_update_data = (
+ "id,description",
+ f"{ike_policies[0].pk},New description",
+ f"{ike_policies[1].pk},New description",
+ f"{ike_policies[2].pk},New description",
+ )
+
+ cls.bulk_edit_data = {
+ 'description': 'New description',
+ 'version': IKEVersionChoices.VERSION_2,
+ 'mode': IKEModeChoices.AGGRESSIVE,
+ }
+
+
+class IPSecProposalTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+ model = IPSecProposal
+
+ @classmethod
+ def setUpTestData(cls):
+
+ ipsec_proposals = (
+ IPSecProposal(
+ name='IPSec Proposal 1',
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+ ),
+ IPSecProposal(
+ name='IPSec Proposal 2',
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+ ),
+ IPSecProposal(
+ name='IPSec Proposal 3',
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+ ),
+ )
+ IPSecProposal.objects.bulk_create(ipsec_proposals)
+
+ tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+ cls.form_data = {
+ 'name': 'IPSec Proposal X',
+ 'encryption_algorithm': EncryptionAlgorithmChoices.ENCRYPTION_AES192_CBC,
+ 'authentication_algorithm': AuthenticationAlgorithmChoices.AUTH_HMAC_SHA256,
+ 'sa_lifetime_seconds': 3600,
+ 'sa_lifetime_data': 1000000,
+ 'tags': [t.pk for t in tags],
+ }
+
+ cls.csv_data = (
+ "name,encryption_algorithm,authentication_algorithm,sa_lifetime_seconds,sa_lifetime_data",
+ "IKE Proposal 4,aes-128-cbc,hmac-sha1,3600,1000000",
+ "IKE Proposal 5,aes-128-cbc,hmac-sha1,3600,1000000",
+ "IKE Proposal 6,aes-128-cbc,hmac-sha1,3600,1000000",
+ )
+
+ cls.csv_update_data = (
+ "id,description",
+ f"{ipsec_proposals[0].pk},New description",
+ f"{ipsec_proposals[1].pk},New description",
+ f"{ipsec_proposals[2].pk},New description",
+ )
+
+ cls.bulk_edit_data = {
+ 'description': 'New description',
+ 'encryption_algorithm': EncryptionAlgorithmChoices.ENCRYPTION_AES192_CBC,
+ 'authentication_algorithm': AuthenticationAlgorithmChoices.AUTH_HMAC_SHA256,
+ 'sa_lifetime_seconds': 3600,
+ 'sa_lifetime_data': 1000000,
+ }
+
+
+class IPSecPolicyTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+ model = IPSecPolicy
+
+ @classmethod
+ def setUpTestData(cls):
+
+ ipsec_proposals = (
+ IPSecProposal(
+ name='IPSec Policy 1',
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1
+ ),
+ IPSecProposal(
+ name='IPSec Proposal 2',
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1
+ ),
+ )
+ IPSecProposal.objects.bulk_create(ipsec_proposals)
+
+ ipsec_policies = (
+ IPSecPolicy(
+ name='IPSec Policy 1',
+ pfs_group=DHGroupChoices.GROUP_14
+ ),
+ IPSecPolicy(
+ name='IPSec Policy 2',
+ pfs_group=DHGroupChoices.GROUP_14
+ ),
+ IPSecPolicy(
+ name='IPSec Policy 3',
+ pfs_group=DHGroupChoices.GROUP_14
+ ),
+ )
+ IPSecPolicy.objects.bulk_create(ipsec_policies)
+ for ipsec_policy in ipsec_policies:
+ ipsec_policy.proposals.set(ipsec_proposals)
+
+ tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+ cls.form_data = {
+ 'name': 'IPSec Policy X',
+ 'pfs_group': DHGroupChoices.GROUP_5,
+ 'proposals': [p.pk for p in ipsec_proposals],
+ 'tags': [t.pk for t in tags],
+ }
+
+ ipsec_proposal_names = ','.join([p.name for p in ipsec_proposals])
+ cls.csv_data = (
+ "name,pfs_group,proposals",
+ f"IKE Proposal 4,19,\"{ipsec_proposal_names}\"",
+ f"IKE Proposal 5,19,\"{ipsec_proposal_names}\"",
+ f"IKE Proposal 6,19,\"{ipsec_proposal_names}\"",
+ )
+
+ cls.csv_update_data = (
+ "id,description",
+ f"{ipsec_policies[0].pk},New description",
+ f"{ipsec_policies[1].pk},New description",
+ f"{ipsec_policies[2].pk},New description",
+ )
+
+ cls.bulk_edit_data = {
+ 'description': 'New description',
+ 'pfs_group': DHGroupChoices.GROUP_5,
+ }
+
+
+class IPSecProfileTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+ model = IPSecProfile
+
+ @classmethod
+ def setUpTestData(cls):
+
+ ike_proposal = IKEProposal.objects.create(
+ name='IKE Proposal 1',
+ authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+ group=DHGroupChoices.GROUP_14
+ )
+
+ ipsec_proposal = IPSecProposal.objects.create(
+ name='IPSec Proposal 1',
+ encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+ authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1
+ )
+
+ ike_policies = (
+ IKEPolicy(
+ name='IKE Policy 1',
+ version=IKEVersionChoices.VERSION_1,
+ mode=IKEModeChoices.MAIN,
+ ),
+ IKEPolicy(
+ name='IKE Policy 2',
+ version=IKEVersionChoices.VERSION_1,
+ mode=IKEModeChoices.MAIN,
+ ),
+ )
+ IKEPolicy.objects.bulk_create(ike_policies)
+ for ike_policy in ike_policies:
+ ike_policy.proposals.add(ike_proposal)
+
+ ipsec_policies = (
+ IPSecPolicy(
+ name='IPSec Policy 1',
+ pfs_group=DHGroupChoices.GROUP_14
+ ),
+ IPSecPolicy(
+ name='IPSec Policy 2',
+ pfs_group=DHGroupChoices.GROUP_14
+ ),
+ )
+ IPSecPolicy.objects.bulk_create(ipsec_policies)
+ for ipsec_policy in ipsec_policies:
+ ipsec_policy.proposals.add(ipsec_proposal)
+
+ ipsec_profiles = (
+ IPSecProfile(
+ name='IPSec Profile 1',
+ mode=IPSecModeChoices.ESP,
+ ike_policy=ike_policies[0],
+ ipsec_policy=ipsec_policies[0]
+ ),
+ IPSecProfile(
+ name='IPSec Profile 2',
+ mode=IPSecModeChoices.ESP,
+ ike_policy=ike_policies[0],
+ ipsec_policy=ipsec_policies[0]
+ ),
+ IPSecProfile(
+ name='IPSec Profile 3',
+ mode=IPSecModeChoices.ESP,
+ ike_policy=ike_policies[0],
+ ipsec_policy=ipsec_policies[0]
+ ),
+ )
+ IPSecProfile.objects.bulk_create(ipsec_profiles)
+
+ tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+ cls.form_data = {
+ 'name': 'IPSec Profile X',
+ 'mode': IPSecModeChoices.AH,
+ 'ike_policy': ike_policies[1].pk,
+ 'ipsec_policy': ipsec_policies[1].pk,
+ 'tags': [t.pk for t in tags],
+ }
+
+ cls.csv_data = (
+ "name,mode,ike_policy,ipsec_policy",
+ f"IKE Proposal 4,ah,IKE Policy 2,IPSec Policy 2",
+ f"IKE Proposal 5,ah,IKE Policy 2,IPSec Policy 2",
+ f"IKE Proposal 6,ah,IKE Policy 2,IPSec Policy 2",
+ )
+
+ cls.csv_update_data = (
+ "id,description",
+ f"{ipsec_profiles[0].pk},New description",
+ f"{ipsec_profiles[1].pk},New description",
+ f"{ipsec_profiles[2].pk},New description",
+ )
+
+ cls.bulk_edit_data = {
+ 'description': 'New description',
+ 'mode': IPSecModeChoices.AH,
+ 'ike_policy': ike_policies[1].pk,
+ 'ipsec_policy': ipsec_policies[1].pk,
+ }
+
+
+class L2VPNTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+ model = L2VPN
+
+ @classmethod
+ def setUpTestData(cls):
+ rts = (
+ RouteTarget(name='64534:123'),
+ RouteTarget(name='64534:321')
+ )
+ RouteTarget.objects.bulk_create(rts)
+
+ l2vpns = (
+ L2VPN(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650001'),
+ L2VPN(name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650002'),
+ L2VPN(name='L2VPN 3', slug='l2vpn-3', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650003')
+ )
+ L2VPN.objects.bulk_create(l2vpns)
+
+ cls.csv_data = (
+ 'name,slug,type,identifier',
+ 'L2VPN 5,l2vpn-5,vxlan,456',
+ 'L2VPN 6,l2vpn-6,vxlan,444',
+ )
+
+ cls.csv_update_data = (
+ 'id,name,description',
+ f'{l2vpns[0].pk},L2VPN 7,New description 7',
+ f'{l2vpns[1].pk},L2VPN 8,New description 8',
+ )
+
+ cls.bulk_edit_data = {
+ 'description': 'New Description',
+ }
+
+ cls.form_data = {
+ 'name': 'L2VPN 8',
+ 'slug': 'l2vpn-8',
+ 'type': L2VPNTypeChoices.TYPE_VXLAN,
+ 'identifier': 123,
+ 'description': 'Description',
+ 'import_targets': [rts[0].pk],
+ 'export_targets': [rts[1].pk]
+ }
+
+
+class L2VPNTerminationTestCase(
+ ViewTestCases.GetObjectViewTestCase,
+ ViewTestCases.GetObjectChangelogViewTestCase,
+ ViewTestCases.CreateObjectViewTestCase,
+ ViewTestCases.EditObjectViewTestCase,
+ ViewTestCases.DeleteObjectViewTestCase,
+ ViewTestCases.ListObjectsViewTestCase,
+ ViewTestCases.BulkImportObjectsViewTestCase,
+ ViewTestCases.BulkDeleteObjectsViewTestCase,
+):
+
+ model = L2VPNTermination
+
+ @classmethod
+ def setUpTestData(cls):
+ device = create_test_device('Device 1')
+ interface = Interface.objects.create(name='Interface 1', device=device, type='1000baset')
+ l2vpns = (
+ L2VPN(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=650001),
+ L2VPN(name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=650002),
+ )
+ L2VPN.objects.bulk_create(l2vpns)
+
+ vlans = (
+ VLAN(name='Vlan 1', vid=1001),
+ VLAN(name='Vlan 2', vid=1002),
+ VLAN(name='Vlan 3', vid=1003),
+ VLAN(name='Vlan 4', vid=1004),
+ VLAN(name='Vlan 5', vid=1005),
+ VLAN(name='Vlan 6', vid=1006)
+ )
+ VLAN.objects.bulk_create(vlans)
+
+ terminations = (
+ L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]),
+ L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[1]),
+ L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[2])
+ )
+ L2VPNTermination.objects.bulk_create(terminations)
+
+ cls.form_data = {
+ 'l2vpn': l2vpns[0].pk,
+ 'device': device.pk,
+ 'interface': interface.pk,
+ }
+
+ cls.csv_data = (
+ "l2vpn,vlan",
+ "L2VPN 1,Vlan 4",
+ "L2VPN 1,Vlan 5",
+ "L2VPN 1,Vlan 6",
+ )
+
+ cls.csv_update_data = (
+ f"id,l2vpn",
+ f"{terminations[0].pk},{l2vpns[0].name}",
+ f"{terminations[1].pk},{l2vpns[0].name}",
+ f"{terminations[2].pk},{l2vpns[0].name}",
+ )
+
+ cls.bulk_edit_data = {}
+
+ # TODO: Fix L2VPNTerminationImportForm validation to support bulk updates
+ def test_bulk_update_objects_with_permission(self):
+ pass
+
+ #
+ # Custom assertions
+ #
+
+ # TODO: Remove this
+ def assertInstanceEqual(self, instance, data, exclude=None, api=False):
+ """
+ Override parent
+ """
+ if exclude is None:
+ exclude = []
+
+ fields = [k for k in data.keys() if k not in exclude]
+ model_dict = self.model_to_dict(instance, fields=fields, api=api)
+
+ # Omit any dictionary keys which are not instance attributes or have been excluded
+ relevant_data = {
+ k: v for k, v in data.items() if hasattr(instance, k) and k not in exclude
+ }
+
+ # Handle relations on the model
+ for k, v in model_dict.items():
+ if isinstance(v, object) and hasattr(v, 'first'):
+ model_dict[k] = v.first().pk
+
+ self.assertDictEqual(model_dict, relevant_data)
diff --git a/netbox/vpn/urls.py b/netbox/vpn/urls.py
new file mode 100644
index 000000000..552f0e185
--- /dev/null
+++ b/netbox/vpn/urls.py
@@ -0,0 +1,89 @@
+from django.urls import include, path
+
+from utilities.urls import get_model_urls
+from . import views
+
+app_name = 'vpn'
+urlpatterns = [
+
+ # Tunnel groups
+ path('tunnel-groups/', views.TunnelGroupListView.as_view(), name='tunnelgroup_list'),
+ path('tunnel-groups/add/', views.TunnelGroupEditView.as_view(), name='tunnelgroup_add'),
+ path('tunnel-groups/import/', views.TunnelGroupBulkImportView.as_view(), name='tunnelgroup_import'),
+ path('tunnel-groups/edit/', views.TunnelGroupBulkEditView.as_view(), name='tunnelgroup_bulk_edit'),
+ path('tunnel-groups/delete/', views.TunnelGroupBulkDeleteView.as_view(), name='tunnelgroup_bulk_delete'),
+ path('tunnel-groups//', include(get_model_urls('vpn', 'tunnelgroup'))),
+
+ # Tunnels
+ path('tunnels/', views.TunnelListView.as_view(), name='tunnel_list'),
+ path('tunnels/add/', views.TunnelEditView.as_view(), name='tunnel_add'),
+ path('tunnels/import/', views.TunnelBulkImportView.as_view(), name='tunnel_import'),
+ path('tunnels/edit/', views.TunnelBulkEditView.as_view(), name='tunnel_bulk_edit'),
+ path('tunnels/delete/', views.TunnelBulkDeleteView.as_view(), name='tunnel_bulk_delete'),
+ path('tunnels//', include(get_model_urls('vpn', 'tunnel'))),
+
+ # Tunnel terminations
+ path('tunnel-terminations/', views.TunnelTerminationListView.as_view(), name='tunneltermination_list'),
+ path('tunnel-terminations/add/', views.TunnelTerminationEditView.as_view(), name='tunneltermination_add'),
+ path('tunnel-terminations/import/', views.TunnelTerminationBulkImportView.as_view(), name='tunneltermination_import'),
+ path('tunnel-terminations/edit/', views.TunnelTerminationBulkEditView.as_view(), name='tunneltermination_bulk_edit'),
+ path('tunnel-terminations/delete/', views.TunnelTerminationBulkDeleteView.as_view(), name='tunneltermination_bulk_delete'),
+ path('tunnel-terminations//', include(get_model_urls('vpn', 'tunneltermination'))),
+
+ # IKE proposals
+ path('ike-proposals/', views.IKEProposalListView.as_view(), name='ikeproposal_list'),
+ path('ike-proposals/add/', views.IKEProposalEditView.as_view(), name='ikeproposal_add'),
+ path('ike-proposals/import/', views.IKEProposalBulkImportView.as_view(), name='ikeproposal_import'),
+ path('ike-proposals/edit/', views.IKEProposalBulkEditView.as_view(), name='ikeproposal_bulk_edit'),
+ path('ike-proposals/delete/', views.IKEProposalBulkDeleteView.as_view(), name='ikeproposal_bulk_delete'),
+ path('ike-proposals//', include(get_model_urls('vpn', 'ikeproposal'))),
+
+ # IKE policies
+ path('ike-policies/', views.IKEPolicyListView.as_view(), name='ikepolicy_list'),
+ path('ike-policies/add/', views.IKEPolicyEditView.as_view(), name='ikepolicy_add'),
+ path('ike-policies/import/', views.IKEPolicyBulkImportView.as_view(), name='ikepolicy_import'),
+ path('ike-policies/edit/', views.IKEPolicyBulkEditView.as_view(), name='ikepolicy_bulk_edit'),
+ path('ike-policies/delete/', views.IKEPolicyBulkDeleteView.as_view(), name='ikepolicy_bulk_delete'),
+ path('ike-policies//', include(get_model_urls('vpn', 'ikepolicy'))),
+
+ # IPSec proposals
+ path('ipsec-proposals/', views.IPSecProposalListView.as_view(), name='ipsecproposal_list'),
+ path('ipsec-proposals/add/', views.IPSecProposalEditView.as_view(), name='ipsecproposal_add'),
+ path('ipsec-proposals/import/', views.IPSecProposalBulkImportView.as_view(), name='ipsecproposal_import'),
+ path('ipsec-proposals/edit/', views.IPSecProposalBulkEditView.as_view(), name='ipsecproposal_bulk_edit'),
+ path('ipsec-proposals/delete/', views.IPSecProposalBulkDeleteView.as_view(), name='ipsecproposal_bulk_delete'),
+ path('ipsec-proposals//', include(get_model_urls('vpn', 'ipsecproposal'))),
+
+ # IPSec policies
+ path('ipsec-policies/', views.IPSecPolicyListView.as_view(), name='ipsecpolicy_list'),
+ path('ipsec-policies/add/', views.IPSecPolicyEditView.as_view(), name='ipsecpolicy_add'),
+ path('ipsec-policies/import/', views.IPSecPolicyBulkImportView.as_view(), name='ipsecpolicy_import'),
+ path('ipsec-policies/edit/', views.IPSecPolicyBulkEditView.as_view(), name='ipsecpolicy_bulk_edit'),
+ path('ipsec-policies/delete/', views.IPSecPolicyBulkDeleteView.as_view(), name='ipsecpolicy_bulk_delete'),
+ path('ipsec-policies//', include(get_model_urls('vpn', 'ipsecpolicy'))),
+
+ # IPSec profiles
+ path('ipsec-profiles/', views.IPSecProfileListView.as_view(), name='ipsecprofile_list'),
+ path('ipsec-profiles/add/', views.IPSecProfileEditView.as_view(), name='ipsecprofile_add'),
+ path('ipsec-profiles/import/', views.IPSecProfileBulkImportView.as_view(), name='ipsecprofile_import'),
+ path('ipsec-profiles/edit/', views.IPSecProfileBulkEditView.as_view(), name='ipsecprofile_bulk_edit'),
+ path('ipsec-profiles/delete/', views.IPSecProfileBulkDeleteView.as_view(), name='ipsecprofile_bulk_delete'),
+ path('ipsec-profiles//', include(get_model_urls('vpn', 'ipsecprofile'))),
+
+ # L2VPN
+ path('l2vpns/', views.L2VPNListView.as_view(), name='l2vpn_list'),
+ path('l2vpns/add/', views.L2VPNEditView.as_view(), name='l2vpn_add'),
+ path('l2vpns/import/', views.L2VPNBulkImportView.as_view(), name='l2vpn_import'),
+ path('l2vpns/edit/', views.L2VPNBulkEditView.as_view(), name='l2vpn_bulk_edit'),
+ path('l2vpns/delete/', views.L2VPNBulkDeleteView.as_view(), name='l2vpn_bulk_delete'),
+ path('l2vpns//', include(get_model_urls('vpn', 'l2vpn'))),
+
+ # L2VPN terminations
+ path('l2vpn-terminations/', views.L2VPNTerminationListView.as_view(), name='l2vpntermination_list'),
+ path('l2vpn-terminations/add/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_add'),
+ path('l2vpn-terminations/import/', views.L2VPNTerminationBulkImportView.as_view(), name='l2vpntermination_import'),
+ path('l2vpn-terminations/edit/', views.L2VPNTerminationBulkEditView.as_view(), name='l2vpntermination_bulk_edit'),
+ path('l2vpn-terminations/delete/', views.L2VPNTerminationBulkDeleteView.as_view(), name='l2vpntermination_bulk_delete'),
+ path('l2vpn-terminations//', include(get_model_urls('vpn', 'l2vpntermination'))),
+
+]
diff --git a/netbox/vpn/views.py b/netbox/vpn/views.py
new file mode 100644
index 000000000..9bf424af9
--- /dev/null
+++ b/netbox/vpn/views.py
@@ -0,0 +1,505 @@
+from ipam.tables import RouteTargetTable
+from netbox.views import generic
+from tenancy.views import ObjectContactsView
+from utilities.utils import count_related
+from utilities.views import register_model_view
+from . import filtersets, forms, tables
+from .models import *
+
+
+#
+# Tunnel groups
+#
+
+class TunnelGroupListView(generic.ObjectListView):
+ queryset = TunnelGroup.objects.annotate(
+ tunnel_count=count_related(Tunnel, 'group')
+ )
+ filterset = filtersets.TunnelGroupFilterSet
+ filterset_form = forms.TunnelGroupFilterForm
+ table = tables.TunnelGroupTable
+
+
+@register_model_view(TunnelGroup)
+class TunnelGroupView(generic.ObjectView):
+ queryset = TunnelGroup.objects.all()
+
+ def get_extra_context(self, request, instance):
+ related_models = (
+ (Tunnel.objects.restrict(request.user, 'view').filter(group=instance), 'group_id'),
+ )
+
+ return {
+ 'related_models': related_models,
+ }
+
+
+@register_model_view(TunnelGroup, 'edit')
+class TunnelGroupEditView(generic.ObjectEditView):
+ queryset = TunnelGroup.objects.all()
+ form = forms.TunnelGroupForm
+
+
+@register_model_view(TunnelGroup, 'delete')
+class TunnelGroupDeleteView(generic.ObjectDeleteView):
+ queryset = TunnelGroup.objects.all()
+
+
+class TunnelGroupBulkImportView(generic.BulkImportView):
+ queryset = TunnelGroup.objects.all()
+ model_form = forms.TunnelGroupImportForm
+
+
+class TunnelGroupBulkEditView(generic.BulkEditView):
+ queryset = TunnelGroup.objects.annotate(
+ tunnel_count=count_related(Tunnel, 'group')
+ )
+ filterset = filtersets.TunnelGroupFilterSet
+ table = tables.TunnelGroupTable
+ form = forms.TunnelGroupBulkEditForm
+
+
+class TunnelGroupBulkDeleteView(generic.BulkDeleteView):
+ queryset = TunnelGroup.objects.annotate(
+ tunnel_count=count_related(Tunnel, 'group')
+ )
+ filterset = filtersets.TunnelGroupFilterSet
+ table = tables.TunnelGroupTable
+
+
+#
+# Tunnels
+#
+
+class TunnelListView(generic.ObjectListView):
+ queryset = Tunnel.objects.annotate(
+ count_terminations=count_related(TunnelTermination, 'tunnel')
+ )
+ filterset = filtersets.TunnelFilterSet
+ filterset_form = forms.TunnelFilterForm
+ table = tables.TunnelTable
+
+
+@register_model_view(Tunnel)
+class TunnelView(generic.ObjectView):
+ queryset = Tunnel.objects.all()
+
+
+@register_model_view(Tunnel, 'edit')
+class TunnelEditView(generic.ObjectEditView):
+ queryset = Tunnel.objects.all()
+ form = forms.TunnelForm
+
+ def dispatch(self, request, *args, **kwargs):
+
+ # If creating a new Tunnel, use the creation form
+ if 'pk' not in kwargs:
+ self.form = forms.TunnelCreateForm
+
+ return super().dispatch(request, *args, **kwargs)
+
+
+@register_model_view(Tunnel, 'delete')
+class TunnelDeleteView(generic.ObjectDeleteView):
+ queryset = Tunnel.objects.all()
+
+
+class TunnelBulkImportView(generic.BulkImportView):
+ queryset = Tunnel.objects.all()
+ model_form = forms.TunnelImportForm
+
+
+class TunnelBulkEditView(generic.BulkEditView):
+ queryset = Tunnel.objects.annotate(
+ count_terminations=count_related(TunnelTermination, 'tunnel')
+ )
+ filterset = filtersets.TunnelFilterSet
+ table = tables.TunnelTable
+ form = forms.TunnelBulkEditForm
+
+
+class TunnelBulkDeleteView(generic.BulkDeleteView):
+ queryset = Tunnel.objects.annotate(
+ count_terminations=count_related(TunnelTermination, 'tunnel')
+ )
+ filterset = filtersets.TunnelFilterSet
+ table = tables.TunnelTable
+
+
+#
+# Tunnel terminations
+#
+
+class TunnelTerminationListView(generic.ObjectListView):
+ queryset = TunnelTermination.objects.all()
+ filterset = filtersets.TunnelTerminationFilterSet
+ filterset_form = forms.TunnelTerminationFilterForm
+ table = tables.TunnelTerminationTable
+
+
+@register_model_view(TunnelTermination)
+class TunnelTerminationView(generic.ObjectView):
+ queryset = TunnelTermination.objects.all()
+
+
+@register_model_view(TunnelTermination, 'edit')
+class TunnelTerminationEditView(generic.ObjectEditView):
+ queryset = TunnelTermination.objects.all()
+ form = forms.TunnelTerminationForm
+
+
+@register_model_view(TunnelTermination, 'delete')
+class TunnelTerminationDeleteView(generic.ObjectDeleteView):
+ queryset = TunnelTermination.objects.all()
+
+
+class TunnelTerminationBulkImportView(generic.BulkImportView):
+ queryset = TunnelTermination.objects.all()
+ model_form = forms.TunnelTerminationImportForm
+
+
+class TunnelTerminationBulkEditView(generic.BulkEditView):
+ queryset = TunnelTermination.objects.all()
+ filterset = filtersets.TunnelTerminationFilterSet
+ table = tables.TunnelTerminationTable
+ form = forms.TunnelTerminationBulkEditForm
+
+
+class TunnelTerminationBulkDeleteView(generic.BulkDeleteView):
+ queryset = TunnelTermination.objects.all()
+ filterset = filtersets.TunnelTerminationFilterSet
+ table = tables.TunnelTerminationTable
+
+
+#
+# IKE proposals
+#
+
+class IKEProposalListView(generic.ObjectListView):
+ queryset = IKEProposal.objects.all()
+ filterset = filtersets.IKEProposalFilterSet
+ filterset_form = forms.IKEProposalFilterForm
+ table = tables.IKEProposalTable
+
+
+@register_model_view(IKEProposal)
+class IKEProposalView(generic.ObjectView):
+ queryset = IKEProposal.objects.all()
+
+
+@register_model_view(IKEProposal, 'edit')
+class IKEProposalEditView(generic.ObjectEditView):
+ queryset = IKEProposal.objects.all()
+ form = forms.IKEProposalForm
+
+
+@register_model_view(IKEProposal, 'delete')
+class IKEProposalDeleteView(generic.ObjectDeleteView):
+ queryset = IKEProposal.objects.all()
+
+
+class IKEProposalBulkImportView(generic.BulkImportView):
+ queryset = IKEProposal.objects.all()
+ model_form = forms.IKEProposalImportForm
+
+
+class IKEProposalBulkEditView(generic.BulkEditView):
+ queryset = IKEProposal.objects.all()
+ filterset = filtersets.IKEProposalFilterSet
+ table = tables.IKEProposalTable
+ form = forms.IKEProposalBulkEditForm
+
+
+class IKEProposalBulkDeleteView(generic.BulkDeleteView):
+ queryset = IKEProposal.objects.all()
+ filterset = filtersets.IKEProposalFilterSet
+ table = tables.IKEProposalTable
+
+
+#
+# IKE policies
+#
+
+class IKEPolicyListView(generic.ObjectListView):
+ queryset = IKEPolicy.objects.all()
+ filterset = filtersets.IKEPolicyFilterSet
+ filterset_form = forms.IKEPolicyFilterForm
+ table = tables.IKEPolicyTable
+
+
+@register_model_view(IKEPolicy)
+class IKEPolicyView(generic.ObjectView):
+ queryset = IKEPolicy.objects.all()
+
+
+@register_model_view(IKEPolicy, 'edit')
+class IKEPolicyEditView(generic.ObjectEditView):
+ queryset = IKEPolicy.objects.all()
+ form = forms.IKEPolicyForm
+
+
+@register_model_view(IKEPolicy, 'delete')
+class IKEPolicyDeleteView(generic.ObjectDeleteView):
+ queryset = IKEPolicy.objects.all()
+
+
+class IKEPolicyBulkImportView(generic.BulkImportView):
+ queryset = IKEPolicy.objects.all()
+ model_form = forms.IKEPolicyImportForm
+
+
+class IKEPolicyBulkEditView(generic.BulkEditView):
+ queryset = IKEPolicy.objects.all()
+ filterset = filtersets.IKEPolicyFilterSet
+ table = tables.IKEPolicyTable
+ form = forms.IKEPolicyBulkEditForm
+
+
+class IKEPolicyBulkDeleteView(generic.BulkDeleteView):
+ queryset = IKEPolicy.objects.all()
+ filterset = filtersets.IKEPolicyFilterSet
+ table = tables.IKEPolicyTable
+
+
+#
+# IPSec proposals
+#
+
+class IPSecProposalListView(generic.ObjectListView):
+ queryset = IPSecProposal.objects.all()
+ filterset = filtersets.IPSecProposalFilterSet
+ filterset_form = forms.IPSecProposalFilterForm
+ table = tables.IPSecProposalTable
+
+
+@register_model_view(IPSecProposal)
+class IPSecProposalView(generic.ObjectView):
+ queryset = IPSecProposal.objects.all()
+
+
+@register_model_view(IPSecProposal, 'edit')
+class IPSecProposalEditView(generic.ObjectEditView):
+ queryset = IPSecProposal.objects.all()
+ form = forms.IPSecProposalForm
+
+
+@register_model_view(IPSecProposal, 'delete')
+class IPSecProposalDeleteView(generic.ObjectDeleteView):
+ queryset = IPSecProposal.objects.all()
+
+
+class IPSecProposalBulkImportView(generic.BulkImportView):
+ queryset = IPSecProposal.objects.all()
+ model_form = forms.IPSecProposalImportForm
+
+
+class IPSecProposalBulkEditView(generic.BulkEditView):
+ queryset = IPSecProposal.objects.all()
+ filterset = filtersets.IPSecProposalFilterSet
+ table = tables.IPSecProposalTable
+ form = forms.IPSecProposalBulkEditForm
+
+
+class IPSecProposalBulkDeleteView(generic.BulkDeleteView):
+ queryset = IPSecProposal.objects.all()
+ filterset = filtersets.IPSecProposalFilterSet
+ table = tables.IPSecProposalTable
+
+
+#
+# IPSec policies
+#
+
+class IPSecPolicyListView(generic.ObjectListView):
+ queryset = IPSecPolicy.objects.all()
+ filterset = filtersets.IPSecPolicyFilterSet
+ filterset_form = forms.IPSecPolicyFilterForm
+ table = tables.IPSecPolicyTable
+
+
+@register_model_view(IPSecPolicy)
+class IPSecPolicyView(generic.ObjectView):
+ queryset = IPSecPolicy.objects.all()
+
+
+@register_model_view(IPSecPolicy, 'edit')
+class IPSecPolicyEditView(generic.ObjectEditView):
+ queryset = IPSecPolicy.objects.all()
+ form = forms.IPSecPolicyForm
+
+
+@register_model_view(IPSecPolicy, 'delete')
+class IPSecPolicyDeleteView(generic.ObjectDeleteView):
+ queryset = IPSecPolicy.objects.all()
+
+
+class IPSecPolicyBulkImportView(generic.BulkImportView):
+ queryset = IPSecPolicy.objects.all()
+ model_form = forms.IPSecPolicyImportForm
+
+
+class IPSecPolicyBulkEditView(generic.BulkEditView):
+ queryset = IPSecPolicy.objects.all()
+ filterset = filtersets.IPSecPolicyFilterSet
+ table = tables.IPSecPolicyTable
+ form = forms.IPSecPolicyBulkEditForm
+
+
+class IPSecPolicyBulkDeleteView(generic.BulkDeleteView):
+ queryset = IPSecPolicy.objects.all()
+ filterset = filtersets.IPSecPolicyFilterSet
+ table = tables.IPSecPolicyTable
+
+
+#
+# IPSec profiles
+#
+
+class IPSecProfileListView(generic.ObjectListView):
+ queryset = IPSecProfile.objects.all()
+ filterset = filtersets.IPSecProfileFilterSet
+ filterset_form = forms.IPSecProfileFilterForm
+ table = tables.IPSecProfileTable
+
+
+@register_model_view(IPSecProfile)
+class IPSecProfileView(generic.ObjectView):
+ queryset = IPSecProfile.objects.all()
+
+
+@register_model_view(IPSecProfile, 'edit')
+class IPSecProfileEditView(generic.ObjectEditView):
+ queryset = IPSecProfile.objects.all()
+ form = forms.IPSecProfileForm
+
+
+@register_model_view(IPSecProfile, 'delete')
+class IPSecProfileDeleteView(generic.ObjectDeleteView):
+ queryset = IPSecProfile.objects.all()
+
+
+class IPSecProfileBulkImportView(generic.BulkImportView):
+ queryset = IPSecProfile.objects.all()
+ model_form = forms.IPSecProfileImportForm
+
+
+class IPSecProfileBulkEditView(generic.BulkEditView):
+ queryset = IPSecProfile.objects.all()
+ filterset = filtersets.IPSecProfileFilterSet
+ table = tables.IPSecProfileTable
+ form = forms.IPSecProfileBulkEditForm
+
+
+class IPSecProfileBulkDeleteView(generic.BulkDeleteView):
+ queryset = IPSecProfile.objects.all()
+ filterset = filtersets.IPSecProfileFilterSet
+ table = tables.IPSecProfileTable
+
+
+# L2VPN
+
+class L2VPNListView(generic.ObjectListView):
+ queryset = L2VPN.objects.all()
+ table = tables.L2VPNTable
+ filterset = filtersets.L2VPNFilterSet
+ filterset_form = forms.L2VPNFilterForm
+
+
+@register_model_view(L2VPN)
+class L2VPNView(generic.ObjectView):
+ queryset = L2VPN.objects.all()
+
+ def get_extra_context(self, request, instance):
+ import_targets_table = RouteTargetTable(
+ instance.import_targets.prefetch_related('tenant'),
+ orderable=False
+ )
+ export_targets_table = RouteTargetTable(
+ instance.export_targets.prefetch_related('tenant'),
+ orderable=False
+ )
+
+ return {
+ 'import_targets_table': import_targets_table,
+ 'export_targets_table': export_targets_table,
+ }
+
+
+@register_model_view(L2VPN, 'edit')
+class L2VPNEditView(generic.ObjectEditView):
+ queryset = L2VPN.objects.all()
+ form = forms.L2VPNForm
+
+
+@register_model_view(L2VPN, 'delete')
+class L2VPNDeleteView(generic.ObjectDeleteView):
+ queryset = L2VPN.objects.all()
+
+
+class L2VPNBulkImportView(generic.BulkImportView):
+ queryset = L2VPN.objects.all()
+ model_form = forms.L2VPNImportForm
+
+
+class L2VPNBulkEditView(generic.BulkEditView):
+ queryset = L2VPN.objects.all()
+ filterset = filtersets.L2VPNFilterSet
+ table = tables.L2VPNTable
+ form = forms.L2VPNBulkEditForm
+
+
+class L2VPNBulkDeleteView(generic.BulkDeleteView):
+ queryset = L2VPN.objects.all()
+ filterset = filtersets.L2VPNFilterSet
+ table = tables.L2VPNTable
+
+
+@register_model_view(L2VPN, 'contacts')
+class L2VPNContactsView(ObjectContactsView):
+ queryset = L2VPN.objects.all()
+
+
+#
+# L2VPN terminations
+#
+
+class L2VPNTerminationListView(generic.ObjectListView):
+ queryset = L2VPNTermination.objects.all()
+ table = tables.L2VPNTerminationTable
+ filterset = filtersets.L2VPNTerminationFilterSet
+ filterset_form = forms.L2VPNTerminationFilterForm
+
+
+@register_model_view(L2VPNTermination)
+class L2VPNTerminationView(generic.ObjectView):
+ queryset = L2VPNTermination.objects.all()
+
+
+@register_model_view(L2VPNTermination, 'edit')
+class L2VPNTerminationEditView(generic.ObjectEditView):
+ queryset = L2VPNTermination.objects.all()
+ form = forms.L2VPNTerminationForm
+ template_name = 'vpn/l2vpntermination_edit.html'
+
+
+@register_model_view(L2VPNTermination, 'delete')
+class L2VPNTerminationDeleteView(generic.ObjectDeleteView):
+ queryset = L2VPNTermination.objects.all()
+
+
+class L2VPNTerminationBulkImportView(generic.BulkImportView):
+ queryset = L2VPNTermination.objects.all()
+ model_form = forms.L2VPNTerminationImportForm
+
+
+class L2VPNTerminationBulkEditView(generic.BulkEditView):
+ queryset = L2VPNTermination.objects.all()
+ filterset = filtersets.L2VPNTerminationFilterSet
+ table = tables.L2VPNTerminationTable
+ form = forms.L2VPNTerminationBulkEditForm
+
+
+class L2VPNTerminationBulkDeleteView(generic.BulkDeleteView):
+ queryset = L2VPNTermination.objects.all()
+ filterset = filtersets.L2VPNTerminationFilterSet
+ table = tables.L2VPNTerminationTable
diff --git a/netbox/wireless/api/views.py b/netbox/wireless/api/views.py
index 1103cec37..a6cc9f535 100644
--- a/netbox/wireless/api/views.py
+++ b/netbox/wireless/api/views.py
@@ -1,6 +1,6 @@
from rest_framework.routers import APIRootView
-from netbox.api.viewsets import NetBoxModelViewSet
+from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin
from wireless import filtersets
from wireless.models import *
from . import serializers
@@ -14,7 +14,7 @@ class WirelessRootView(APIRootView):
return 'Wireless'
-class WirelessLANGroupViewSet(NetBoxModelViewSet):
+class WirelessLANGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
queryset = WirelessLANGroup.objects.add_related_count(
WirelessLANGroup.objects.all(),
WirelessLAN,
diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py
index 046918535..0b114f85f 100644
--- a/netbox/wireless/models.py
+++ b/netbox/wireless/models.py
@@ -2,7 +2,6 @@ from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
-from mptt.models import MPTTModel
from dcim.choices import LinkStatusChoices
from dcim.constants import WIRELESS_IFACE_TYPES
@@ -214,14 +213,14 @@ class WirelessLink(WirelessAuthenticationBase, PrimaryModel):
if self.interface_a.type not in WIRELESS_IFACE_TYPES:
raise ValidationError({
'interface_a': _(
- "{type_display} is not a wireless interface."
- ).format(type_display=self.interface_a.get_type_display())
+ "{type} is not a wireless interface."
+ ).format(type=self.interface_a.get_type_display())
})
if self.interface_b.type not in WIRELESS_IFACE_TYPES:
raise ValidationError({
'interface_a': _(
- "{type_display} is not a wireless interface."
- ).format(type_display=self.interface_b.get_type_display())
+ "{type} is not a wireless interface."
+ ).format(type=self.interface_b.get_type_display())
})
def save(self, *args, **kwargs):
diff --git a/netbox/wireless/search.py b/netbox/wireless/search.py
index 1f8097cd7..c8ac023cc 100644
--- a/netbox/wireless/search.py
+++ b/netbox/wireless/search.py
@@ -11,6 +11,7 @@ class WirelessLANIndex(SearchIndex):
('auth_psk', 2000),
('comments', 5000),
)
+ display_attrs = ('group', 'status', 'vlan', 'tenant', 'description')
@register_search
@@ -21,6 +22,7 @@ class WirelessLANGroupIndex(SearchIndex):
('slug', 110),
('description', 500),
)
+ display_attrs = ('description',)
@register_search
@@ -32,3 +34,4 @@ class WirelessLinkIndex(SearchIndex):
('auth_psk', 2000),
('comments', 5000),
)
+ display_attrs = ('status', 'tenant', 'description')
diff --git a/netbox/wireless/tests/test_filtersets.py b/netbox/wireless/tests/test_filtersets.py
index 0629fea07..4184d5392 100644
--- a/netbox/wireless/tests/test_filtersets.py
+++ b/netbox/wireless/tests/test_filtersets.py
@@ -36,6 +36,10 @@ class WirelessLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
for group in child_groups:
group.save()
+ def test_q(self):
+ params = {'q': 'foobar1'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
def test_name(self):
params = {'name': ['Wireless LAN Group 1', 'Wireless LAN Group 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -103,7 +107,8 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests):
tenant=tenants[0],
auth_type=WirelessAuthTypeChoices.TYPE_OPEN,
auth_cipher=WirelessAuthCipherChoices.CIPHER_AUTO,
- auth_psk='PSK1'
+ auth_psk='PSK1',
+ description='foobar1'
),
WirelessLAN(
ssid='WLAN2',
@@ -113,7 +118,8 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests):
tenant=tenants[1],
auth_type=WirelessAuthTypeChoices.TYPE_WEP,
auth_cipher=WirelessAuthCipherChoices.CIPHER_TKIP,
- auth_psk='PSK2'
+ auth_psk='PSK2',
+ description='foobar2'
),
WirelessLAN(
ssid='WLAN3',
@@ -123,11 +129,16 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests):
tenant=tenants[2],
auth_type=WirelessAuthTypeChoices.TYPE_WPA_PERSONAL,
auth_cipher=WirelessAuthCipherChoices.CIPHER_AES,
- auth_psk='PSK3'
+ auth_psk='PSK3',
+ description='foobar3'
),
)
WirelessLAN.objects.bulk_create(wireless_lans)
+ def test_q(self):
+ params = {'q': 'foobar1'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
def test_ssid(self):
params = {'ssid': ['WLAN1', 'WLAN2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -160,6 +171,10 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'auth_psk': ['PSK1', 'PSK2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ def test_description(self):
+ params = {'description': ['foobar1', 'foobar2']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
def test_tenant(self):
tenants = Tenant.objects.all()[:2]
params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
@@ -240,6 +255,10 @@ class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests):
ssid='LINK4'
).save()
+ def test_q(self):
+ params = {'q': 'foobar1'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
def test_ssid(self):
params = {'ssid': ['LINK1', 'LINK2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
diff --git a/requirements.txt b/requirements.txt
index 7580af9cd..1ab2caaea 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,35 +1,35 @@
-bleach==6.0.0
-Django==4.2.4
-django-cors-headers==4.2.0
+bleach==6.1.0
+Django==4.2.8
+django-cors-headers==4.3.1
django-debug-toolbar==4.2.0
-django-filter==23.2
+django-filter==23.5
django-graphiql-debug-toolbar==0.2.0
-django-mptt==0.14
+django-mptt==0.14.0
django-pglocks==1.0.4
django-prometheus==2.3.1
-django-redis==5.3.0
-django-rich==1.7.0
-django-rq==2.8.1
-django-tables2==2.6.0
-django-taggit==4.0.0
-django-timezone-field==5.1
+django-redis==5.4.0
+django-rich==1.8.0
+django-rq==2.10.1
+django-taggit==5.0.1
+django-tables2==2.7.0
+django-timezone-field==6.1.0
djangorestframework==3.14.0
-drf-spectacular==0.26.4
-drf-spectacular-sidecar==2023.8.1
-feedparser==6.0.10
+drf-spectacular==0.27.0
+drf-spectacular-sidecar==2023.12.1
+feedparser==6.0.11
graphene-django==3.0.0
gunicorn==21.2.0
Jinja2==3.1.2
-Markdown==3.3.7
-mkdocs-material==9.1.21
-mkdocstrings[python-legacy]==0.22.0
-netaddr==0.8.0
-Pillow==10.0.0
-psycopg[binary,pool]==3.1.10
+Markdown==3.5.1
+mkdocs-material==9.5.3
+mkdocstrings[python-legacy]==0.24.0
+netaddr==0.9.0
+Pillow==10.1.0
+psycopg[binary,pool]==3.1.16
PyYAML==6.0.1
-sentry-sdk==1.29.2
-social-auth-app-django==5.2.0
-social-auth-core[openidconnect]==4.4.2
+requests==2.31.0
+social-auth-app-django==5.4.0
+social-auth-core[openidconnect]==4.5.1
strawberry-graphql-django==0.16.0
svgwrite==1.4.3
tablib==3.5.0
diff --git a/upgrade.sh b/upgrade.sh
index cac046a9f..47b3b108a 100755
--- a/upgrade.sh
+++ b/upgrade.sh
@@ -7,6 +7,10 @@
# Python 3.8 or later.
cd "$(dirname "$0")"
+
+NETBOX_VERSION="$(grep ^VERSION netbox/netbox/settings.py | cut -d\' -f2)"
+echo "You are installing (or upgrading to) NetBox version ${NETBOX_VERSION}"
+
VIRTUALENV="$(pwd -P)/venv"
PYTHON="${PYTHON:-python3}"
@@ -113,11 +117,6 @@ COMMAND="python3 netbox/manage.py clearsessions"
echo "Removing expired user sessions ($COMMAND)..."
eval $COMMAND || exit 1
-# Clear the cache
-COMMAND="python3 netbox/manage.py clearcache"
-echo "Clearing the cache ($COMMAND)..."
-eval $COMMAND || exit 1
-
if [ -v WARN_MISSING_VENV ]; then
echo "--------------------------------------------------------------------"
echo "WARNING: No existing virtual environment was detected. A new one has"