From 60fc28e37d41b5b7c0ea30227e8f00c776fefd4f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 7 Nov 2023 16:49:03 -0500 Subject: [PATCH 01/27] WIP --- netbox/netbox/navigation/menu.py | 21 +++- netbox/netbox/settings.py | 1 + netbox/netbox/urls.py | 2 + netbox/vpn/__init__.py | 0 netbox/vpn/admin.py | 3 + netbox/vpn/api/__init__.py | 0 netbox/vpn/api/nested_serializers.py | 40 +++++++ netbox/vpn/api/serializers.py | 117 ++++++++++++++++++++ netbox/vpn/api/urls.py | 11 ++ netbox/vpn/api/views.py | 46 ++++++++ netbox/vpn/apps.py | 9 ++ netbox/vpn/choices.py | 108 +++++++++++++++++++ netbox/vpn/filtersets.py | 137 ++++++++++++++++++++++++ netbox/vpn/forms/__init__.py | 4 + netbox/vpn/forms/bulk_edit.py | 140 ++++++++++++++++++++++++ netbox/vpn/forms/bulk_import.py | 153 +++++++++++++++++++++++++++ netbox/vpn/forms/filtersets.py | 124 ++++++++++++++++++++++ netbox/vpn/forms/model_forms.py | 96 +++++++++++++++++ netbox/vpn/migrations/__init__.py | 0 netbox/vpn/models/__init__.py | 2 + netbox/vpn/models/crypto.py | 86 +++++++++++++++ netbox/vpn/models/tunnels.py | 115 ++++++++++++++++++++ netbox/vpn/search.py | 0 netbox/vpn/tables.py | 124 ++++++++++++++++++++++ netbox/vpn/urls.py | 33 ++++++ netbox/vpn/views.py | 146 +++++++++++++++++++++++++ 26 files changed, 1514 insertions(+), 4 deletions(-) create mode 100644 netbox/vpn/__init__.py create mode 100644 netbox/vpn/admin.py create mode 100644 netbox/vpn/api/__init__.py create mode 100644 netbox/vpn/api/nested_serializers.py create mode 100644 netbox/vpn/api/serializers.py create mode 100644 netbox/vpn/api/urls.py create mode 100644 netbox/vpn/api/views.py create mode 100644 netbox/vpn/apps.py create mode 100644 netbox/vpn/choices.py create mode 100644 netbox/vpn/filtersets.py create mode 100644 netbox/vpn/forms/__init__.py create mode 100644 netbox/vpn/forms/bulk_edit.py create mode 100644 netbox/vpn/forms/bulk_import.py create mode 100644 netbox/vpn/forms/filtersets.py create mode 100644 netbox/vpn/forms/model_forms.py create mode 100644 netbox/vpn/migrations/__init__.py create mode 100644 netbox/vpn/models/__init__.py create mode 100644 netbox/vpn/models/crypto.py create mode 100644 netbox/vpn/models/tunnels.py create mode 100644 netbox/vpn/search.py create mode 100644 netbox/vpn/tables.py create mode 100644 netbox/vpn/urls.py create mode 100644 netbox/vpn/views.py diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 961fd2035ac..904106ecf04 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -195,17 +195,30 @@ ), ) -OVERLAY_MENU = Menu( - label=_('Overlay'), +VPN_MENU = Menu( + label=_('VPN'), icon_class='mdi mdi-graph-outline', groups=( MenuGroup( - label='L2VPNs', + label=_('Tunnels'), + items=( + get_model_item('vpn', 'tunnel', _('Tunnels')), + get_model_item('vpn', 'tunneltermination', _('Tunnel Terminations')), + ), + ), + MenuGroup( + label=_('L2VPNs'), items=( get_model_item('ipam', 'l2vpn', _('L2VPNs')), get_model_item('ipam', 'l2vpntermination', _('Terminations')), ), ), + MenuGroup( + label=_('Security'), + items=( + get_model_item('vpn', 'ipsecprofile', _('IPSec Profiles')), + ), + ), ), ) @@ -443,7 +456,7 @@ CONNECTIONS_MENU, WIRELESS_MENU, IPAM_MENU, - OVERLAY_MENU, + VPN_MENU, VIRTUALIZATION_MENU, CIRCUITS_MENU, POWER_MENU, diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 465389a1129..ce8ab5876fd 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -379,6 +379,7 @@ def _setting(name, default=None): 'users', 'utilities', 'virtualization', + 'vpn', 'wireless', 'django_rq', # Must come after extras to allow overriding management commands 'drf_spectacular', diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 6955426a8df..9843589113b 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -33,6 +33,7 @@ path('tenancy/', include('tenancy.urls')), path('users/', include('users.urls')), path('virtualization/', include('virtualization.urls')), + path('vpn/', include('vpn.urls')), path('wireless/', include('wireless.urls')), # Current user views @@ -51,6 +52,7 @@ path('api/tenancy/', include('tenancy.api.urls')), path('api/users/', include('users.api.urls')), path('api/virtualization/', include('virtualization.api.urls')), + path('api/vpn/', include('vpn.api.urls')), path('api/wireless/', include('wireless.api.urls')), path('api/status/', StatusView.as_view(), name='api-status'), diff --git a/netbox/vpn/__init__.py b/netbox/vpn/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/netbox/vpn/admin.py b/netbox/vpn/admin.py new file mode 100644 index 00000000000..8c38f3f3dad --- /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 00000000000..e69de29bb2d diff --git a/netbox/vpn/api/nested_serializers.py b/netbox/vpn/api/nested_serializers.py new file mode 100644 index 00000000000..7ab1654b942 --- /dev/null +++ b/netbox/vpn/api/nested_serializers.py @@ -0,0 +1,40 @@ +from rest_framework import serializers + +from netbox.api.serializers import WritableNestedSerializer +from vpn import models + +__all__ = ( + 'NestedIPSecProfileSerializer', + 'NestedTunnelSerializer', + 'NestedTunnelTerminationSerializer', +) + + +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 NestedIPSecProfileSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='vpn-api:ipsecprofile-detail' + ) + + class Meta: + model = models.IPSecProfile + fields = ('id', 'url', 'display', 'name') diff --git a/netbox/vpn/api/serializers.py b/netbox/vpn/api/serializers.py new file mode 100644 index 00000000000..803db2acd23 --- /dev/null +++ b/netbox/vpn/api/serializers.py @@ -0,0 +1,117 @@ +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 +from netbox.api.fields import ChoiceField, ContentTypeField +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__ = ( + 'IPSecProfileSerializer', + 'TunnelSerializer', + 'TunnelTerminationSerializer', +) + + +class TunnelSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='vpn-api:tunnel-detail' + ) + status = ChoiceField( + choices=TunnelStatusChoices + ) + 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', 'encapsulation', 'ipsec_profile', 'tenant', 'preshared_key', + 'tunnel_id', '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 + ) + interface_type = ContentTypeField( + queryset=ContentType.objects.all() + ) + interface = serializers.SerializerMethodField( + read_only=True + ) + outside_ip = NestedIPAddressSerializer( + required=False, + allow_null=True + ) + + class Meta: + model = TunnelTermination + fields = ( + 'id', 'url', 'display', 'tunnel', 'role', 'interface_type', 'interface_id', 'interface', 'outside_ip', + 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', + ) + + @extend_schema_field(serializers.JSONField(allow_null=True)) + def get_interface(self, obj): + serializer = get_serializer_for_model(obj.interface, prefix=NESTED_SERIALIZER_PREFIX) + context = {'request': self.context['request']} + return serializer(obj.assigned_object, context=context).data + + +class IPSecProfileSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='vpn-api:ipsecprofile-detail' + ) + protocol = ChoiceField( + choices=IPSecProtocolChoices + ) + ike_version = ChoiceField( + choices=IKEVersionChoices + ) + phase1_encryption = ChoiceField( + choices=EncryptionChoices + ) + phase1_authentication = ChoiceField( + choices=AuthenticationChoices + ) + phase1_group = ChoiceField( + choices=DHGroupChoices + ) + phase2_encryption = ChoiceField( + choices=EncryptionChoices + ) + phase2_authentication = ChoiceField( + choices=AuthenticationChoices + ) + phase2_group = ChoiceField( + choices=DHGroupChoices + ) + + class Meta: + model = IPSecProfile + fields = ( + 'id', 'url', 'display', 'name', 'protocol', 'ike_version', 'phase1_encryption', 'phase1_authentication', + 'phase1_group', 'phase1_sa_lifetime', 'phase2_encryption', 'phase2_authentication', 'phase2_group', + 'phase2_sa_lifetime', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + ) diff --git a/netbox/vpn/api/urls.py b/netbox/vpn/api/urls.py new file mode 100644 index 00000000000..084514e35ba --- /dev/null +++ b/netbox/vpn/api/urls.py @@ -0,0 +1,11 @@ +from netbox.api.routers import NetBoxRouter +from . import views + +router = NetBoxRouter() +router.APIRootView = views.VPNRootView +router.register('ipsec-profiles', views.IPSecProfileViewSet) +router.register('tunnels', views.TunnelViewSet) +router.register('tunnel-terminations', views.TunnelTerminationViewSet) + +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 00000000000..6cb99f2a7f8 --- /dev/null +++ b/netbox/vpn/api/views.py @@ -0,0 +1,46 @@ +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__ = ( + 'IPSecProfileViewSet', + 'TunnelTerminationViewSet', + 'TunnelViewSet', + 'VPNRootView', +) + + +class VPNRootView(APIRootView): + """ + VPN API root view + """ + def get_view_name(self): + return 'VPN' + + +# +# Viewsets +# + +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 = Tunnel.objects.prefetch_related('tunnel') + serializer_class = serializers.TunnelTerminationSerializer + filterset_class = filtersets.TunnelTerminationFilterSet + + +class IPSecProfileViewSet(NetBoxModelViewSet): + queryset = IPSecProfile.objects.all() + serializer_class = serializers.IPSecProfileSerializer + filterset_class = filtersets.IPSecProfileFilterSet diff --git a/netbox/vpn/apps.py b/netbox/vpn/apps.py new file mode 100644 index 00000000000..2254befd3ac --- /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 00000000000..24a7b8c8ddf --- /dev/null +++ b/netbox/vpn/choices.py @@ -0,0 +1,108 @@ +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 = 'ipsec' + + CHOICES = [ + (ENCAP_IPSEC, _('IPsec')), + (ENCAP_IP_IP, _('Active')), + (ENCAP_GRE, _('Disabled')), + ] + + +class TunnelTerminationRoleChoices(ChoiceSet): + ROLE_PEER = 'peer' + ROLE_HUB = 'hub' + ROLE_SPOKE = 'spoke' + + CHOICES = [ + (ROLE_PEER, _('Peer')), + (ROLE_HUB, _('Hub')), + (ROLE_SPOKE, _('Spoke')), + ] + + +# +# IKE +# + +class IPSecProtocolChoices(ChoiceSet): + PROTOCOL_ESP = 'esp' + PROTOCOL_AH = 'ah' + + CHOICES = ( + (PROTOCOL_ESP, 'ESP'), + (PROTOCOL_AH, 'AH'), + ) + + +class IKEVersionChoices(ChoiceSet): + VERSION_1 = 1 + VERSION_2 = 2 + + CHOICES = ( + (VERSION_1, 'IKEv1'), + (VERSION_2, 'IKEv2'), + ) + + +class EncryptionChoices(ChoiceSet): + ENCRYPTION_AES128 = 'aes-128' + ENCRYPTION_AES192 = 'aes-192' + ENCRYPTION_AES256 = 'aes-256' + ENCRYPTION_3DES = '3des' + + CHOICES = ( + (ENCRYPTION_AES128, 'AES (128-bit)'), + (ENCRYPTION_AES192, 'AES (192-bit)'), + (ENCRYPTION_AES256, 'AES (256-bit)'), + (ENCRYPTION_3DES, '3DES'), + ) + + +class AuthenticationChoices(ChoiceSet): + AUTH_SHA1 = 'SHA-1' + AUTH_MD5 = 'MD5' + + CHOICES = ( + (AUTH_SHA1, 'SHA-1'), + (AUTH_MD5, 'MD5'), + ) + + +class DHGroupChoices(ChoiceSet): + # TODO: Add all the groups & annotate their attributes + GROUP_1 = 1 + GROUP_2 = 2 + GROUP_5 = 5 + GROUP_7 = 7 + + CHOICES = ( + (GROUP_1, _('Group {n}').format(n=1)), + (GROUP_2, _('Group {n}').format(n=2)), + (GROUP_5, _('Group {n}').format(n=5)), + (GROUP_7, _('Group {n}').format(n=7)), + ) diff --git a/netbox/vpn/filtersets.py b/netbox/vpn/filtersets.py new file mode 100644 index 00000000000..061aea41823 --- /dev/null +++ b/netbox/vpn/filtersets.py @@ -0,0 +1,137 @@ +import django_filters +from django.db.models import Q +from django.utils.translation import gettext as _ + +from dcim.models import Interface +from ipam.models import IPAddress +from netbox.filtersets import NetBoxModelFilterSet +from tenancy.filtersets import TenancyFilterSet +from virtualization.models import VMInterface +from .choices import * +from .models import * + +__all__ = ( + 'IPSecProfileFilterSet', + 'TunnelFilterSet', + 'TunnelTerminationFilterSet', +) + + +class TunnelFilterSet(NetBoxModelFilterSet, TenancyFilterSet): + status = django_filters.MultipleChoiceFilter( + choices=TunnelStatusChoices + ) + 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', 'preshared_key', 'tunnel_id'] + + 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=IPSecProfile.objects.all(), + to_field_name='name', + label=_('Tunnel (name)'), + ) + role = django_filters.MultipleChoiceFilter( + choices=TunnelTerminationRoleChoices + ) + # 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='interface__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 IPSecProfileFilterSet(NetBoxModelFilterSet): + protocol = django_filters.MultipleChoiceFilter( + choices=IPSecProtocolChoices + ) + ike_version = django_filters.MultipleChoiceFilter( + choices=IKEVersionChoices + ) + phase1_encryption = django_filters.MultipleChoiceFilter( + choices=EncryptionChoices + ) + phase1_authentication = django_filters.MultipleChoiceFilter( + choices=AuthenticationChoices + ) + phase1_group = django_filters.MultipleChoiceFilter( + choices=DHGroupChoices + ) + phase2_encryption = django_filters.MultipleChoiceFilter( + choices=EncryptionChoices + ) + phase2_authentication = django_filters.MultipleChoiceFilter( + choices=AuthenticationChoices + ) + phase2_group = django_filters.MultipleChoiceFilter( + choices=DHGroupChoices + ) + + class Meta: + model = IPSecProfile + fields = ['id', 'name', 'phase1_sa_lifetime', 'phase2_sa_lifetime'] + + 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) + ) diff --git a/netbox/vpn/forms/__init__.py b/netbox/vpn/forms/__init__.py new file mode 100644 index 00000000000..1499f98b281 --- /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 00000000000..db33cc95beb --- /dev/null +++ b/netbox/vpn/forms/bulk_edit.py @@ -0,0 +1,140 @@ +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__ = ( + 'IPSecProfileBulkEditForm', + 'TunnelBulkEditForm', + 'TunnelTerminationBulkEditForm', +) + + +class TunnelBulkEditForm(NetBoxModelBulkEditForm): + status = forms.ChoiceField( + label=_('Status'), + choices=add_blank_choice(TunnelStatusChoices), + 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 + ) + preshared_key = forms.CharField( + label=_('Pre-shared key'), + 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', 'encapsulation', 'tunnel_id', 'description')), + (_('Security'), ('ipsec_profile', 'preshared_key')), + (_('Tenancy'), ('tenant',)), + ) + nullable_fields = ( + 'ipsec_profile', 'preshared_key', 'tunnel_id', 'tenant', 'description', 'comments', + ) + + +class TunnelTerminationBulkEditForm(NetBoxModelBulkEditForm): + role = forms.ChoiceField( + label=_('Role'), + choices=add_blank_choice(TunnelTerminationRoleChoices), + required=False + ) + + model = TunnelTermination + fieldsets = ( + (None, ('role',)), + ) + + +class IPSecProfileBulkEditForm(NetBoxModelBulkEditForm): + protocol = forms.ChoiceField( + label=_('Protocol'), + choices=add_blank_choice(IPSecProtocolChoices), + required=False + ) + ike_version = forms.ChoiceField( + label=_('IKE version'), + choices=add_blank_choice(IKEVersionChoices), + required=False + ) + description = forms.CharField( + label=_('Description'), + max_length=200, + required=False + ) + phase1_encryption = forms.ChoiceField( + label=_('Encryption'), + choices=add_blank_choice(EncryptionChoices), + required=False + ) + phase1_authentication = forms.ChoiceField( + label=_('Authentication'), + choices=add_blank_choice(AuthenticationChoices), + required=False + ) + phase1_group = forms.ChoiceField( + label=_('Group'), + choices=add_blank_choice(DHGroupChoices), + required=False + ) + phase1_sa_lifetime = forms.IntegerField( + required=False + ) + phase2_encryption = forms.ChoiceField( + label=_('Encryption'), + choices=add_blank_choice(EncryptionChoices), + required=False + ) + phase2_authentication = forms.ChoiceField( + label=_('Authentication'), + choices=add_blank_choice(AuthenticationChoices), + required=False + ) + phase2_group = forms.ChoiceField( + label=_('Group'), + choices=add_blank_choice(DHGroupChoices), + required=False + ) + phase2_sa_lifetime = forms.IntegerField( + required=False + ) + comments = CommentField() + + model = IPSecProfile + fieldsets = ( + (_('Profile'), ('protocol', 'ike_version', 'description')), + (_('Phase 1 Parameters'), ('phase1_encryption', 'phase1_authentication', 'phase1_group', 'phase1_sa_lifetime')), + (_('Phase 2 Parameters'), ('phase2_encryption', 'phase2_authentication', 'phase2_group', 'phase2_sa_lifetime')), + ) + nullable_fields = ( + 'description', 'phase1_sa_lifetime', 'phase2_sa_lifetime', 'comments', + ) diff --git a/netbox/vpn/forms/bulk_import.py b/netbox/vpn/forms/bulk_import.py new file mode 100644 index 00000000000..61e9a49993b --- /dev/null +++ b/netbox/vpn/forms/bulk_import.py @@ -0,0 +1,153 @@ +from django.utils.translation import gettext_lazy as _ + +from dcim.models import Device, Interface +from ipam.models import IPAddress +from netbox.forms import NetBoxModelImportForm +from tenancy.models import Tenant +from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField +from virtualization.models import VirtualMachine, VMInterface +from vpn.choices import * +from vpn.models import * + +__all__ = ( + 'IPSecProfileImportForm', + 'TunnelImportForm', + 'TunnelTerminationImportForm', +) + + +class TunnelImportForm(NetBoxModelImportForm): + status = CSVChoiceField( + label=_('Status'), + choices=TunnelStatusChoices, + help_text=_('Operational status') + ) + encapsulation = CSVChoiceField( + label=_('Encapsulation'), + choices=TunnelEncapsulationChoices, + help_text=_('Tunnel encapsulation') + ) + ipsec_profile = CSVModelChoiceField( + label=_('IPSec profile'), + queryset=IPSecProfile.objects.all(), + 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', 'encapsulation', 'ipsec_profile', 'tenant', 'preshared_key', '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') + ) + interface = CSVModelChoiceField( + label=_('Interface'), + queryset=Interface.objects.none(), # Can also refer to VMInterface + required=False, + to_field_name='name', + help_text=_('Assigned interface') + ) + outside_ip = CSVModelChoiceField( + label=_('Outside IP'), + queryset=IPAddress.objects.all(), + 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 interface queryset by assigned device/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']} + ) + + +class IPSecProfileImportForm(NetBoxModelImportForm): + protocol = CSVChoiceField( + label=_('Protocol'), + choices=IPSecProtocolChoices, + help_text=_('IPSec protocol') + ) + ike_version = CSVChoiceField( + label=_('IKE version'), + choices=IKEVersionChoices, + help_text=_('IKE version') + ) + phase1_encryption = CSVChoiceField( + label=_('Phase 1 Encryption'), + choices=EncryptionChoices + ) + phase1_authentication = CSVChoiceField( + label=_('Phase 1 Authentication'), + choices=AuthenticationChoices + ) + phase1_group = CSVChoiceField( + label=_('Phase 1 Group'), + choices=DHGroupChoices + ) + phase2_encryption = CSVChoiceField( + label=_('Phase 2 Encryption'), + choices=EncryptionChoices + ) + phase2_authentication = CSVChoiceField( + label=_('Phase 2 Authentication'), + choices=AuthenticationChoices + ) + phase2_group = CSVChoiceField( + label=_('Phase 2 Group'), + choices=DHGroupChoices + ) + + class Meta: + model = IPSecProfile + fields = ( + 'name', 'protocol', 'ike_version', 'phase1_encryption', 'phase1_authentication', 'phase1_group', + 'phase1_sa_lifetime', 'phase1_encryption', 'phase1_authentication', 'phase1_group', 'phase1_sa_lifetime', + 'description', 'comments', 'tags', + ) diff --git a/netbox/vpn/forms/filtersets.py b/netbox/vpn/forms/filtersets.py new file mode 100644 index 00000000000..9f11de5a3be --- /dev/null +++ b/netbox/vpn/forms/filtersets.py @@ -0,0 +1,124 @@ +from django import forms +from django.utils.translation import gettext as _ + +from netbox.forms import NetBoxModelFilterSetForm +from utilities.forms.fields import DynamicModelMultipleChoiceField, TagFilterField +from vpn.choices import * +from vpn.models import * + +__all__ = ( + 'IPSecProfileFilterForm', + 'TunnelFilterForm', + 'TunnelTerminationFilterForm', +) + + +class TunnelFilterForm(NetBoxModelFilterSetForm): + model = Tunnel + fieldsets = ( + (None, ('q', 'filter_id', 'tag')), + (_('Tunnel'), ('status', 'encapsulation', 'tunnel_id')), + (_('Security'), ('ipsec_profile_id', 'preshared_key')), + (_('Tenancy'), ('tenant',)), + ) + status = forms.MultipleChoiceField( + label=_('Status'), + choices=TunnelStatusChoices, + required=False + ) + encapsulation = forms.MultipleChoiceField( + label=_('Encapsulation'), + choices=TunnelEncapsulationChoices, + required=False + ) + ipsec_profile_id = DynamicModelMultipleChoiceField( + queryset=IPSecProfile.objects.all(), + required=False, + label=_('IPSec profile') + ) + 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 IPSecProfileFilterForm(NetBoxModelFilterSetForm): + model = IPSecProfile + fieldsets = ( + (None, ('q', 'filter_id', 'tag')), + (_('Profile'), ('protocol', 'ike_version')), + (_('Phase 1 Parameters'), ('phase1_encryption', 'phase1_authentication', 'phase1_group', 'phase1_sa_lifetime')), + (_('Phase 2 Parameters'), ('phase2_encryption', 'phase2_authentication', 'phase2_group', 'phase2_sa_lifetime')), + ) + protocol = forms.MultipleChoiceField( + label=_('Protocol'), + choices=IPSecProtocolChoices, + required=False + ) + ike_version = forms.MultipleChoiceField( + label=_('IKE version'), + choices=IKEVersionChoices, + required=False + ) + ipsec_profile_id = DynamicModelMultipleChoiceField( + queryset=IPSecProfile.objects.all(), + required=False, + label=_('IPSec profile') + ) + phase1_encryption = forms.MultipleChoiceField( + label=_('Encryption'), + choices=EncryptionChoices, + required=False + ) + phase1_authentication = forms.MultipleChoiceField( + label=_('Authentication'), + choices=AuthenticationChoices, + required=False + ) + phase1_group = forms.MultipleChoiceField( + label=_('Group'), + choices=DHGroupChoices, + required=False + ) + phase1_sa_lifetime = forms.IntegerField( + required=False, + min_value=0, + label=_('SA lifetime') + ) + phase2_encryption = forms.MultipleChoiceField( + label=_('Encryption'), + choices=EncryptionChoices, + required=False + ) + phase2_authentication = forms.MultipleChoiceField( + label=_('Authentication'), + choices=AuthenticationChoices, + required=False + ) + phase2_group = forms.MultipleChoiceField( + label=_('Group'), + choices=DHGroupChoices, + required=False + ) + phase2_sa_lifetime = forms.IntegerField( + required=False, + min_value=0, + label=_('SA lifetime') + ) + tag = TagFilterField(model) diff --git a/netbox/vpn/forms/model_forms.py b/netbox/vpn/forms/model_forms.py new file mode 100644 index 00000000000..2621cdd461d --- /dev/null +++ b/netbox/vpn/forms/model_forms.py @@ -0,0 +1,96 @@ +from django import forms +from django.utils.translation import gettext_lazy as _ + +from dcim.models import Interface +from netbox.forms import NetBoxModelForm +from tenancy.forms import TenancyForm +from utilities.forms.fields import CommentField, DynamicModelChoiceField +from virtualization.models import VMInterface +from vpn.models import * + +__all__ = ( + 'IPSecProfileForm', + 'TunnelForm', + 'TunnelTerminationForm', +) + + +class TunnelForm(TenancyForm, NetBoxModelForm): + ipsec_profile = DynamicModelChoiceField( + queryset=IPSecProfile.objects.all() + ) + comments = CommentField() + + fieldsets = ( + (_('Tunnel'), ('name', 'status', 'encapsulation', 'description', 'tunnel_id', 'tags')), + (_('Security'), ('ipsec_profile', 'preshared_key')), + (_('Tenancy'), ('tenant_group', 'tenant')), + ) + + class Meta: + model = Tunnel + fields = [ + 'name', 'status', 'encapsulation', 'description', 'tunnel_id', 'ipsec_profile', 'preshared_key', + 'tenant_group', 'tenant', 'comments', 'tags', + ] + + +class TunnelTerminationForm(NetBoxModelForm): + tunnel = DynamicModelChoiceField( + queryset=Tunnel.objects.all() + ) + 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 = TunnelTermination + fields = [ + 'tunnel', 'role', 'outside_ip', 'tags', + ] + + def __init__(self, *args, **kwargs): + + # Initialize helper selectors + initial = kwargs.get('initial', {}).copy() + if instance := kwargs.get('instance'): + if type(instance.interface) is Interface: + initial['interface'] = instance.interface + elif type(instance.interface) is VMInterface: + initial['vminterface'] = instance.interface + kwargs['initial'] = initial + + super().__init__(*args, **kwargs) + + def clean(self): + super().clean() + + # Handle interface assignment + self.instance.interface = self.cleaned_data['interface'] or self.cleaned_data['interface'] or None + + +class IPSecProfileForm(NetBoxModelForm): + comments = CommentField() + + fieldsets = ( + (_('Profile'), ('name', 'protocol', 'ike_version', 'description', 'tags')), + (_('Phase 1 Parameters'), ('phase1_encryption', 'phase1_authentication', 'phase1_group', 'phase1_sa_lifetime')), + (_('Phase 2 Parameters'), ('phase2_encryption', 'phase2_authentication', 'phase2_group', 'phase2_sa_lifetime')), + ) + + class Meta: + model = IPSecProfile + fields = [ + 'name', 'protocol', 'ike_version', 'phase1_encryption', 'phase1_authentication', 'phase1_group', + 'phase1_sa_lifetime', 'phase2_encryption', 'phase2_authentication', 'phase2_group', 'phase2_sa_lifetime', + 'description', 'comments', 'tags', + ] diff --git a/netbox/vpn/migrations/__init__.py b/netbox/vpn/migrations/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/netbox/vpn/models/__init__.py b/netbox/vpn/models/__init__.py new file mode 100644 index 00000000000..3b70eb41839 --- /dev/null +++ b/netbox/vpn/models/__init__.py @@ -0,0 +1,2 @@ +from .crypto import * +from .tunnels import * diff --git a/netbox/vpn/models/crypto.py b/netbox/vpn/models/crypto.py new file mode 100644 index 00000000000..8e1ee28706d --- /dev/null +++ b/netbox/vpn/models/crypto.py @@ -0,0 +1,86 @@ +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__ = ( + 'IPSecProfile', +) + + +class IPSecProfile(PrimaryModel): + name = models.CharField( + verbose_name=_('name'), + max_length=100, + unique=True + ) + protocol = models.CharField( + verbose_name=_('protocol'), + choices=IPSecProtocolChoices + ) + ike_version = models.PositiveSmallIntegerField( + verbose_name=_('IKE version'), + choices=IKEVersionChoices, + default=IKEVersionChoices.VERSION_2 + ) + + # Phase 1 parameters + phase1_encryption = models.CharField( + verbose_name=_('phase 1 encryption'), + choices=EncryptionChoices + ) + phase1_authentication = models.CharField( + verbose_name=_('phase 1 authentication'), + choices=AuthenticationChoices + ) + phase1_group = models.PositiveSmallIntegerField( + verbose_name=_('phase 1 group'), + choices=DHGroupChoices, + help_text=_('Diffie-Hellman group') + ) + phase1_sa_lifetime = models.PositiveSmallIntegerField( + verbose_name=_('phase 1 SA lifetime'), + blank=True, + null=True, + help_text=_('Security association lifetime (in seconds)') + ) + + # Phase 2 parameters + phase2_encryption = models.CharField( + verbose_name=_('phase 2 encryption'), + choices=EncryptionChoices + ) + phase2_authentication = models.CharField( + verbose_name=_('phase 2 authentication'), + choices=AuthenticationChoices + ) + phase2_group = models.PositiveSmallIntegerField( + verbose_name=_('phase 2 group'), + choices=DHGroupChoices, + help_text=_('Diffie-Hellman group') + ) + phase2_sa_lifetime = models.PositiveSmallIntegerField( + verbose_name=_('phase 2 SA lifetime'), + blank=True, + null=True, + help_text=_('Security association lifetime (in seconds)') + ) + # TODO: Add PFS group? + + clone_fields = ( + 'protocol', 'ike_version', 'phase1_encryption', 'phase1_authentication', 'phase1_group', 'phase1_as_lifetime', + 'phase2_encryption', 'phase2_authentication', 'phase2_group', 'phase2_as_lifetime', + ) + + class Meta: + ordering = ('name',) + verbose_name = _('tunnel') + verbose_name_plural = _('tunnels') + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('vpn:ipsecprofile', args=[self.pk]) diff --git a/netbox/vpn/models/tunnels.py b/netbox/vpn/models/tunnels.py new file mode 100644 index 00000000000..4912ac3cd02 --- /dev/null +++ b/netbox/vpn/models/tunnels.py @@ -0,0 +1,115 @@ +from django.contrib.contenttypes.fields import GenericForeignKey +from django.db import models +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ + +from netbox.models import ChangeLoggedModel, PrimaryModel +from netbox.models.features import CustomFieldsMixin, CustomLinksMixin, TagsMixin +from vpn.choices import * + +__all__ = ( + 'Tunnel', + 'TunnelTermination', +) + + +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 + ) + 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 + ) + preshared_key = models.TextField( + verbose_name=_('pre-shared key'), + blank=True + ) + tunnel_id = models.PositiveBigIntegerField( + verbose_name=_('tunnel ID'), + blank=True + ) + + clone_fields = ( + 'status', 'encapsulation', 'ipsec_profile', 'tenant', + ) + + class Meta: + ordering = ('name',) + 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 + ) + interface_type = models.ForeignKey( + to='contenttypes.ContentType', + on_delete=models.PROTECT, + related_name='+' + ) + interface_id = models.PositiveBigIntegerField( + blank=True, + null=True + ) + interface = GenericForeignKey( + ct_field='interface_type', + fk_field='interface_id' + ) + outside_ip = models.OneToOneField( + to='ipam.IPAddress', + on_delete=models.PROTECT, + related_name='tunnel_termination' + ) + + class Meta: + ordering = ('tunnel', 'pk') + 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 self.tunnel.get_absolute_url() diff --git a/netbox/vpn/search.py b/netbox/vpn/search.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/netbox/vpn/tables.py b/netbox/vpn/tables.py new file mode 100644 index 00000000000..4f8b08066dc --- /dev/null +++ b/netbox/vpn/tables.py @@ -0,0 +1,124 @@ +import django_tables2 as tables +from django.utils.translation import gettext_lazy as _ +from django_tables2.utils import Accessor + +from tenancy.tables import TenancyColumnsMixin +from netbox.tables import NetBoxTable, columns +from vpn.models import * + +__all__ = ( + 'IPSecProfileTable', + 'TunnelTable', + 'TunnelTerminationTable', +) + + +class TunnelTable(TenancyColumnsMixin, NetBoxTable): + name = tables.Column( + verbose_name=_('Name'), + linkify=True + ) + status = columns.ChoiceFieldColumn( + verbose_name=_('Status') + ) + encapsulation = columns.ChoiceFieldColumn( + verbose_name=_('Encapsulation') + ) + 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', 'preshared_key', + 'tunnel_id', 'termination_count', 'description', 'comments', 'tags', 'created', 'last_updated', + ) + default_columns = ('pk', 'name', 'status', 'encapsulation', 'tenant', 'termination_count') + + +class TunnelTerminationTable(TenancyColumnsMixin, NetBoxTable): + tunnel = tables.Column( + verbose_name=_('Tunnel'), + linkify=True + ) + role = columns.ChoiceFieldColumn( + verbose_name=_('Role') + ) + interface = tables.Column( + verbose_name=_('Interface'), + linkify=True + ) + 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', 'interface', 'outside_ip', 'tags', 'created', 'last_updated', + ) + default_columns = ('pk', 'tunnel', 'role', 'interface', 'outside_ip') + + +class IPSecProfileTable(TenancyColumnsMixin, NetBoxTable): + name = tables.Column( + verbose_name=_('Name'), + linkify=True + ) + protocol = columns.ChoiceFieldColumn( + verbose_name=_('Protocol') + ) + ike_version = columns.ChoiceFieldColumn( + verbose_name=_('IKE Version') + ) + phase1_encryption = columns.ChoiceFieldColumn( + verbose_name=_('Phase 1 Encryption') + ) + phase1_authentication = columns.ChoiceFieldColumn( + verbose_name=_('Phase 1 Authentication') + ) + phase1_group = columns.ChoiceFieldColumn( + verbose_name=_('Phase 1 Group') + ) + phase2_encryption = columns.ChoiceFieldColumn( + verbose_name=_('Phase 2 Encryption') + ) + phase2_authentication = columns.ChoiceFieldColumn( + verbose_name=_('Phase 2 Authentication') + ) + phase2_group = columns.ChoiceFieldColumn( + verbose_name=_('Phase 2 Group') + ) + comments = columns.MarkdownColumn( + verbose_name=_('Comments'), + ) + tags = columns.TagColumn( + url_name='vpn:tunnel_list' + ) + + class Meta(NetBoxTable.Meta): + model = IPSecProfile + fields = ( + 'pk', 'id', 'name', 'protocol', 'ike_version', 'phase1_encryption', 'phase1_authentication', 'phase1_group', + 'phase1_sa_lifetime', 'phase2_encryption', 'phase2_authentication', 'phase2_group', 'phase1_sa_lifetime', + 'description', 'comments', 'tags', 'created', 'last_updated', + ) + default_columns = ('pk', 'name', 'protocol', 'ike_version', 'description') diff --git a/netbox/vpn/urls.py b/netbox/vpn/urls.py new file mode 100644 index 00000000000..bfb348a14e1 --- /dev/null +++ b/netbox/vpn/urls.py @@ -0,0 +1,33 @@ +from django.urls import include, path + +from utilities.urls import get_model_urls +from . import views + +app_name = 'vpn' +urlpatterns = [ + + # 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'))), + + # 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'))), + +] diff --git a/netbox/vpn/views.py b/netbox/vpn/views.py new file mode 100644 index 00000000000..58034391a09 --- /dev/null +++ b/netbox/vpn/views.py @@ -0,0 +1,146 @@ +from netbox.views import generic +from utilities.utils import count_related +from utilities.views import register_model_view +from . import filtersets, forms, tables +from .models import * + + +# +# 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 + + +@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 + + +# +# 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 From 4880111622a160b3a36ba2653d0c1f863a215d1d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 8 Nov 2023 12:23:55 -0500 Subject: [PATCH 02/27] WIP --- netbox/core/management/commands/nbshell.py | 2 +- netbox/netbox/api/views.py | 1 + netbox/templates/vpn/ipsecprofile.html | 92 ++++++++++++++++++ netbox/templates/vpn/tunnel.html | 81 ++++++++++++++++ netbox/vpn/api/serializers.py | 4 +- netbox/vpn/api/views.py | 2 +- netbox/vpn/choices.py | 16 ++-- netbox/vpn/forms/filtersets.py | 13 ++- netbox/vpn/forms/model_forms.py | 9 +- netbox/vpn/graphql/__init__.py | 0 netbox/vpn/graphql/schema.py | 26 ++++++ netbox/vpn/graphql/types.py | 33 +++++++ netbox/vpn/migrations/0001_initial.py | 93 +++++++++++++++++++ ...psecprofile_phase1_sa_lifetime_and_more.py | 23 +++++ .../migrations/0003_alter_tunnel_tunnel_id.py | 18 ++++ netbox/vpn/models/crypto.py | 8 +- netbox/vpn/models/tunnels.py | 6 +- netbox/vpn/tables.py | 5 +- netbox/vpn/views.py | 5 - 19 files changed, 409 insertions(+), 28 deletions(-) create mode 100644 netbox/templates/vpn/ipsecprofile.html create mode 100644 netbox/templates/vpn/tunnel.html create mode 100644 netbox/vpn/graphql/__init__.py create mode 100644 netbox/vpn/graphql/schema.py create mode 100644 netbox/vpn/graphql/types.py create mode 100644 netbox/vpn/migrations/0001_initial.py create mode 100644 netbox/vpn/migrations/0002_alter_ipsecprofile_phase1_sa_lifetime_and_more.py create mode 100644 netbox/vpn/migrations/0003_alter_tunnel_tunnel_id.py diff --git a/netbox/core/management/commands/nbshell.py b/netbox/core/management/commands/nbshell.py index 674a878c754..fd86627d273 100644 --- a/netbox/core/management/commands/nbshell.py +++ b/netbox/core/management/commands/nbshell.py @@ -9,7 +9,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.management.base import BaseCommand -APPS = ('circuits', 'core', 'dcim', 'extras', 'ipam', 'tenancy', 'users', 'virtualization', 'wireless') +APPS = ('circuits', 'core', 'dcim', 'extras', 'ipam', 'tenancy', 'users', 'virtualization', 'vpn', 'wireless') BANNER_TEXT = """### NetBox interactive shell ({node}) ### Python {python} | Django {django} | NetBox {netbox} diff --git a/netbox/netbox/api/views.py b/netbox/netbox/api/views.py index 4e71ca193ce..cfbe82f14f2 100644 --- a/netbox/netbox/api/views.py +++ b/netbox/netbox/api/views.py @@ -39,6 +39,7 @@ def get(self, request, format=None): 'tenancy': reverse('tenancy-api:api-root', request=request, format=format), 'users': reverse('users-api:api-root', request=request, format=format), 'virtualization': reverse('virtualization-api:api-root', request=request, format=format), + 'vpn': reverse('vpn-api:api-root', request=request, format=format), 'wireless': reverse('wireless-api:api-root', request=request, format=format), }) diff --git a/netbox/templates/vpn/ipsecprofile.html b/netbox/templates/vpn/ipsecprofile.html new file mode 100644 index 00000000000..3f50c39ab96 --- /dev/null +++ b/netbox/templates/vpn/ipsecprofile.html @@ -0,0 +1,92 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load i18n %} + +{% block content %} +
+
+
+
{% trans "IPSec Profile" %}
+
+ + + + + + + + + + + + + + + + + +
{% trans "Name" %}{{ object.name }}
{% trans "Protocol" %}{{ object.get_protocol_display }}
{% trans "IKE Version" %}{{ object.get_ike_version_display }}
{% trans "Description" %}{{ object.description|placeholder }}
+
+
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + {% plugin_left_page object %} +
+
+
+
{% trans "Phase 1 Parameters" %}
+
+ + + + + + + + + + + + + + + + + +
{% trans "Encryption" %}{{ object.get_phase1_encryption_display }}
{% trans "Authentication" %}{{ object.get_phase1_authentication_display }}
{% trans "DH Group" %}{{ object.get_phase1_group_display }}
{% trans "SA Lifetime" %}{{ object.phase1_sa_lifetime|placeholder }}
+
+
+
+
{% trans "Phase 2 Parameters" %}
+
+ + + + + + + + + + + + + + + + + +
{% trans "Encryption" %}{{ object.get_phase2_encryption_display }}
{% trans "Authentication" %}{{ object.get_phase2_authentication_display }}
{% trans "DH Group" %}{{ object.get_phase2_group_display }}
{% trans "SA Lifetime" %}{{ object.phase2_sa_lifetime|placeholder }}
+
+
+ {% plugin_right_page object %} +
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/vpn/tunnel.html b/netbox/templates/vpn/tunnel.html new file mode 100644 index 00000000000..2420a1230ad --- /dev/null +++ b/netbox/templates/vpn/tunnel.html @@ -0,0 +1,81 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load i18n %} + +{% block content %} +
+
+
+
{% trans "Tunnel" %}
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Name" %}{{ object.name }}
{% trans "Status" %}{% badge object.get_status_display bg_color=object.get_status_color %}
{% trans "Encapsulation" %}{{ object.get_encapsulation_display }}
{% trans "IPSec profile" %}{{ object.ipsec_profile|linkify|placeholder }}
{% trans "Tenant" %} + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / + {% endif %} + {{ object.tenant|linkify|placeholder }} +
{% trans "Pre-shared key" %}{{ object.preshared_key|placeholder }}
{% trans "Tunnel ID" %}{{ object.tunnel_id|placeholder }}
{% trans "Description" %}{{ object.description|placeholder }}
+
+
+ {% plugin_left_page object %} +
+
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + {% plugin_right_page object %} +
+
+
+
+
+
{% trans "Terminations" %}
+
+ {% if perms.vpn.add_tunneltermination %} + + {% endif %} +
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/vpn/api/serializers.py b/netbox/vpn/api/serializers.py index 803db2acd23..c342110a3b5 100644 --- a/netbox/vpn/api/serializers.py +++ b/netbox/vpn/api/serializers.py @@ -69,14 +69,14 @@ class Meta: model = TunnelTermination fields = ( 'id', 'url', 'display', 'tunnel', 'role', 'interface_type', 'interface_id', 'interface', 'outside_ip', - 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', + 'tags', 'custom_fields', 'created', 'last_updated', ) @extend_schema_field(serializers.JSONField(allow_null=True)) def get_interface(self, obj): serializer = get_serializer_for_model(obj.interface, prefix=NESTED_SERIALIZER_PREFIX) context = {'request': self.context['request']} - return serializer(obj.assigned_object, context=context).data + return serializer(obj.interface, context=context).data class IPSecProfileSerializer(NetBoxModelSerializer): diff --git a/netbox/vpn/api/views.py b/netbox/vpn/api/views.py index 6cb99f2a7f8..7d01c48cf17 100644 --- a/netbox/vpn/api/views.py +++ b/netbox/vpn/api/views.py @@ -35,7 +35,7 @@ class TunnelViewSet(NetBoxModelViewSet): class TunnelTerminationViewSet(NetBoxModelViewSet): - queryset = Tunnel.objects.prefetch_related('tunnel') + queryset = TunnelTermination.objects.prefetch_related('tunnel') serializer_class = serializers.TunnelTerminationSerializer filterset_class = filtersets.TunnelTerminationFilterSet diff --git a/netbox/vpn/choices.py b/netbox/vpn/choices.py index 24a7b8c8ddf..96d73633f4a 100644 --- a/netbox/vpn/choices.py +++ b/netbox/vpn/choices.py @@ -24,12 +24,14 @@ class TunnelStatusChoices(ChoiceSet): class TunnelEncapsulationChoices(ChoiceSet): ENCAP_GRE = 'gre' ENCAP_IP_IP = 'ip-ip' - ENCAP_IPSEC = 'ipsec' + ENCAP_IPSEC_TRANSPORT = 'ipsec-transport' + ENCAP_IPSEC_TUNNEL = 'ipsec-tunnel' CHOICES = [ - (ENCAP_IPSEC, _('IPsec')), - (ENCAP_IP_IP, _('Active')), - (ENCAP_GRE, _('Disabled')), + (ENCAP_IPSEC_TRANSPORT, _('IPsec - Transport')), + (ENCAP_IPSEC_TUNNEL, _('IPsec - Tunnel')), + (ENCAP_IP_IP, _('IP-in-IP')), + (ENCAP_GRE, _('GRE')), ] @@ -39,9 +41,9 @@ class TunnelTerminationRoleChoices(ChoiceSet): ROLE_SPOKE = 'spoke' CHOICES = [ - (ROLE_PEER, _('Peer')), - (ROLE_HUB, _('Hub')), - (ROLE_SPOKE, _('Spoke')), + (ROLE_PEER, _('Peer'), 'green'), + (ROLE_HUB, _('Hub'), 'blue'), + (ROLE_SPOKE, _('Spoke'), 'orange'), ] diff --git a/netbox/vpn/forms/filtersets.py b/netbox/vpn/forms/filtersets.py index 9f11de5a3be..53a3bd63490 100644 --- a/netbox/vpn/forms/filtersets.py +++ b/netbox/vpn/forms/filtersets.py @@ -2,6 +2,7 @@ from django.utils.translation import gettext as _ from netbox.forms import NetBoxModelFilterSetForm +from tenancy.forms import TenancyFilterForm from utilities.forms.fields import DynamicModelMultipleChoiceField, TagFilterField from vpn.choices import * from vpn.models import * @@ -13,13 +14,13 @@ ) -class TunnelFilterForm(NetBoxModelFilterSetForm): +class TunnelFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Tunnel fieldsets = ( (None, ('q', 'filter_id', 'tag')), (_('Tunnel'), ('status', 'encapsulation', 'tunnel_id')), (_('Security'), ('ipsec_profile_id', 'preshared_key')), - (_('Tenancy'), ('tenant',)), + (_('Tenancy'), ('tenant_group_id', 'tenant_id')), ) status = forms.MultipleChoiceField( label=_('Status'), @@ -36,6 +37,14 @@ class TunnelFilterForm(NetBoxModelFilterSetForm): required=False, label=_('IPSec profile') ) + preshared_key = forms.CharField( + required=False, + label=_('Pre-shared key') + ) + tunnel_id = forms.IntegerField( + required=False, + label=_('Tunnel ID') + ) tag = TagFilterField(model) diff --git a/netbox/vpn/forms/model_forms.py b/netbox/vpn/forms/model_forms.py index 2621cdd461d..9755bd538c6 100644 --- a/netbox/vpn/forms/model_forms.py +++ b/netbox/vpn/forms/model_forms.py @@ -2,6 +2,7 @@ from django.utils.translation import gettext_lazy as _ from dcim.models import Interface +from ipam.models import IPAddress from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from utilities.forms.fields import CommentField, DynamicModelChoiceField @@ -17,7 +18,8 @@ class TunnelForm(TenancyForm, NetBoxModelForm): ipsec_profile = DynamicModelChoiceField( - queryset=IPSecProfile.objects.all() + queryset=IPSecProfile.objects.all(), + label=_('IPSec Profile') ) comments = CommentField() @@ -51,6 +53,11 @@ class TunnelTerminationForm(NetBoxModelForm): selector=True, label=_('Interface'), ) + outside_ip = DynamicModelChoiceField( + queryset=IPAddress.objects.all(), + selector=True, + label=_('Outside IP'), + ) class Meta: model = TunnelTermination diff --git a/netbox/vpn/graphql/__init__.py b/netbox/vpn/graphql/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/netbox/vpn/graphql/schema.py b/netbox/vpn/graphql/schema.py new file mode 100644 index 00000000000..0ec4cd20746 --- /dev/null +++ b/netbox/vpn/graphql/schema.py @@ -0,0 +1,26 @@ +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): + 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) + + tunnel = ObjectField(TunnelType) + tunnel_list = ObjectListField(TunnelType) + + def resolve_tunnel_list(root, info, **kwargs): + return gql_query_optimizer(models.Tunnel.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 00000000000..d6b04ad2f5d --- /dev/null +++ b/netbox/vpn/graphql/types.py @@ -0,0 +1,33 @@ +from extras.graphql.mixins import CustomFieldsMixin, TagsMixin +from netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObjectType +from vpn import filtersets, models + +__all__ = ( + 'IPSecProfileType', + 'TunnelTerminationType', + 'TunnelType', +) + + +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 IPSecProfileType(OrganizationalObjectType): + + class Meta: + model = models.IPSecProfile + fields = '__all__' + filterset_class = filtersets.IPSecProfileFilterSet diff --git a/netbox/vpn/migrations/0001_initial.py b/netbox/vpn/migrations/0001_initial.py new file mode 100644 index 00000000000..0bb11185974 --- /dev/null +++ b/netbox/vpn/migrations/0001_initial.py @@ -0,0 +1,93 @@ +# Generated by Django 4.2.6 on 2023-11-07 21:49 + +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers +import utilities.json + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('tenancy', '0011_contactassignment_tags'), + ('extras', '0099_cachedvalue_ordering'), + ('contenttypes', '0002_remove_content_type_name'), + ('ipam', '0067_ipaddress_index_host'), + ] + + operations = [ + 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)), + ('protocol', models.CharField()), + ('ike_version', models.PositiveSmallIntegerField(default=2)), + ('phase1_encryption', models.CharField()), + ('phase1_authentication', models.CharField()), + ('phase1_group', models.PositiveSmallIntegerField()), + ('phase1_sa_lifetime', models.PositiveSmallIntegerField(blank=True, null=True)), + ('phase2_encryption', models.CharField()), + ('phase2_authentication', models.CharField()), + ('phase2_group', models.PositiveSmallIntegerField()), + ('phase2_sa_lifetime', models.PositiveSmallIntegerField(blank=True, null=True)), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'verbose_name': 'tunnel', + 'verbose_name_plural': 'tunnels', + 'ordering': ('name',), + }, + ), + 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)), + ('encapsulation', models.CharField(max_length=50)), + ('preshared_key', models.TextField(blank=True)), + ('tunnel_id', models.PositiveBigIntegerField(blank=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.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)), + ('interface_id', models.PositiveBigIntegerField(blank=True, null=True)), + ('interface_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')), + ('outside_ip', models.OneToOneField(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', 'pk'), + }, + ), + ] diff --git a/netbox/vpn/migrations/0002_alter_ipsecprofile_phase1_sa_lifetime_and_more.py b/netbox/vpn/migrations/0002_alter_ipsecprofile_phase1_sa_lifetime_and_more.py new file mode 100644 index 00000000000..f07076c5014 --- /dev/null +++ b/netbox/vpn/migrations/0002_alter_ipsecprofile_phase1_sa_lifetime_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.6 on 2023-11-08 16:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vpn', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='ipsecprofile', + name='phase1_sa_lifetime', + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='ipsecprofile', + name='phase2_sa_lifetime', + field=models.PositiveIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/vpn/migrations/0003_alter_tunnel_tunnel_id.py b/netbox/vpn/migrations/0003_alter_tunnel_tunnel_id.py new file mode 100644 index 00000000000..b02b4aa861b --- /dev/null +++ b/netbox/vpn/migrations/0003_alter_tunnel_tunnel_id.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.6 on 2023-11-08 16:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vpn', '0002_alter_ipsecprofile_phase1_sa_lifetime_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='tunnel', + name='tunnel_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/vpn/models/crypto.py b/netbox/vpn/models/crypto.py index 8e1ee28706d..8c817b296ed 100644 --- a/netbox/vpn/models/crypto.py +++ b/netbox/vpn/models/crypto.py @@ -40,7 +40,7 @@ class IPSecProfile(PrimaryModel): choices=DHGroupChoices, help_text=_('Diffie-Hellman group') ) - phase1_sa_lifetime = models.PositiveSmallIntegerField( + phase1_sa_lifetime = models.PositiveIntegerField( verbose_name=_('phase 1 SA lifetime'), blank=True, null=True, @@ -61,7 +61,7 @@ class IPSecProfile(PrimaryModel): choices=DHGroupChoices, help_text=_('Diffie-Hellman group') ) - phase2_sa_lifetime = models.PositiveSmallIntegerField( + phase2_sa_lifetime = models.PositiveIntegerField( verbose_name=_('phase 2 SA lifetime'), blank=True, null=True, @@ -70,8 +70,8 @@ class IPSecProfile(PrimaryModel): # TODO: Add PFS group? clone_fields = ( - 'protocol', 'ike_version', 'phase1_encryption', 'phase1_authentication', 'phase1_group', 'phase1_as_lifetime', - 'phase2_encryption', 'phase2_authentication', 'phase2_group', 'phase2_as_lifetime', + 'protocol', 'ike_version', 'phase1_encryption', 'phase1_authentication', 'phase1_group', 'phase1_sa_lifetime', + 'phase2_encryption', 'phase2_authentication', 'phase2_group', 'phase2_sa_lifetime', ) class Meta: diff --git a/netbox/vpn/models/tunnels.py b/netbox/vpn/models/tunnels.py index 4912ac3cd02..b48a30a4ffd 100644 --- a/netbox/vpn/models/tunnels.py +++ b/netbox/vpn/models/tunnels.py @@ -50,7 +50,8 @@ class Tunnel(PrimaryModel): ) tunnel_id = models.PositiveBigIntegerField( verbose_name=_('tunnel ID'), - blank=True + blank=True, + null=True ) clone_fields = ( @@ -113,3 +114,6 @@ def __str__(self): def get_absolute_url(self): return self.tunnel.get_absolute_url() + + def get_role_color(self): + return TunnelTerminationRoleChoices.colors.get(self.role) diff --git a/netbox/vpn/tables.py b/netbox/vpn/tables.py index 4f8b08066dc..3d589abca59 100644 --- a/netbox/vpn/tables.py +++ b/netbox/vpn/tables.py @@ -21,9 +21,6 @@ class TunnelTable(TenancyColumnsMixin, NetBoxTable): status = columns.ChoiceFieldColumn( verbose_name=_('Status') ) - encapsulation = columns.ChoiceFieldColumn( - verbose_name=_('Encapsulation') - ) ipsec_profile = tables.Column( verbose_name=_('IPSec profile'), linkify=True @@ -47,7 +44,7 @@ class Meta(NetBoxTable.Meta): 'pk', 'id', 'name', 'status', 'encapsulation', 'ipsec_profile', 'tenant', 'tenant_group', 'preshared_key', 'tunnel_id', 'termination_count', 'description', 'comments', 'tags', 'created', 'last_updated', ) - default_columns = ('pk', 'name', 'status', 'encapsulation', 'tenant', 'termination_count') + default_columns = ('pk', 'name', 'status', 'encapsulation', 'tenant', 'terminations_count') class TunnelTerminationTable(TenancyColumnsMixin, NetBoxTable): diff --git a/netbox/vpn/views.py b/netbox/vpn/views.py index 58034391a09..9ea4fd215fd 100644 --- a/netbox/vpn/views.py +++ b/netbox/vpn/views.py @@ -67,11 +67,6 @@ class TunnelTerminationListView(generic.ObjectListView): 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() From 130288d08af21b92f34dfea7930cff1a002f6e66 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 14 Nov 2023 16:47:12 -0500 Subject: [PATCH 03/27] Add phase2_sa_lifetime_data field on IPSecProfile --- netbox/templates/vpn/ipsecprofile.html | 6 ++++- netbox/vpn/api/serializers.py | 3 ++- netbox/vpn/filtersets.py | 2 +- netbox/vpn/forms/bulk_edit.py | 18 +++++++++++---- netbox/vpn/forms/bulk_import.py | 4 ++-- netbox/vpn/forms/filtersets.py | 14 +++++++++-- netbox/vpn/forms/model_forms.py | 16 +++++++++---- netbox/vpn/migrations/0001_initial.py | 7 +++--- ...psecprofile_phase1_sa_lifetime_and_more.py | 23 ------------------- .../migrations/0003_alter_tunnel_tunnel_id.py | 18 --------------- netbox/vpn/models/crypto.py | 12 +++++++--- netbox/vpn/tables.py | 4 ++-- 12 files changed, 62 insertions(+), 65 deletions(-) delete mode 100644 netbox/vpn/migrations/0002_alter_ipsecprofile_phase1_sa_lifetime_and_more.py delete mode 100644 netbox/vpn/migrations/0003_alter_tunnel_tunnel_id.py diff --git a/netbox/templates/vpn/ipsecprofile.html b/netbox/templates/vpn/ipsecprofile.html index 3f50c39ab96..d2247bdd07a 100644 --- a/netbox/templates/vpn/ipsecprofile.html +++ b/netbox/templates/vpn/ipsecprofile.html @@ -75,9 +75,13 @@
{% trans "Phase 2 Parameters" %}
{{ object.get_phase2_group_display }} - {% trans "SA Lifetime" %} + {% trans "SA Lifetime (Seconds)" %} {{ object.phase2_sa_lifetime|placeholder }} + + {% trans "SA Lifetime (KB)" %} + {{ object.phase2_sa_lifetime_data|placeholder }} + diff --git a/netbox/vpn/api/serializers.py b/netbox/vpn/api/serializers.py index c342110a3b5..a65305dfe75 100644 --- a/netbox/vpn/api/serializers.py +++ b/netbox/vpn/api/serializers.py @@ -113,5 +113,6 @@ class Meta: fields = ( 'id', 'url', 'display', 'name', 'protocol', 'ike_version', 'phase1_encryption', 'phase1_authentication', 'phase1_group', 'phase1_sa_lifetime', 'phase2_encryption', 'phase2_authentication', 'phase2_group', - 'phase2_sa_lifetime', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + 'phase2_sa_lifetime', 'phase2_sa_lifetime_data', 'comments', 'tags', 'custom_fields', 'created', + 'last_updated', ) diff --git a/netbox/vpn/filtersets.py b/netbox/vpn/filtersets.py index 061aea41823..99dcde379f2 100644 --- a/netbox/vpn/filtersets.py +++ b/netbox/vpn/filtersets.py @@ -125,7 +125,7 @@ class IPSecProfileFilterSet(NetBoxModelFilterSet): class Meta: model = IPSecProfile - fields = ['id', 'name', 'phase1_sa_lifetime', 'phase2_sa_lifetime'] + fields = ['id', 'name', 'phase1_sa_lifetime', 'phase2_sa_lifetime', 'phase2_sa_lifetime_data'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/vpn/forms/bulk_edit.py b/netbox/vpn/forms/bulk_edit.py index db33cc95beb..6969235f7bb 100644 --- a/netbox/vpn/forms/bulk_edit.py +++ b/netbox/vpn/forms/bulk_edit.py @@ -127,14 +127,24 @@ class IPSecProfileBulkEditForm(NetBoxModelBulkEditForm): phase2_sa_lifetime = forms.IntegerField( required=False ) + phase2_sa_lifetime_data = forms.IntegerField( + required=False + ) comments = CommentField() model = IPSecProfile fieldsets = ( - (_('Profile'), ('protocol', 'ike_version', 'description')), - (_('Phase 1 Parameters'), ('phase1_encryption', 'phase1_authentication', 'phase1_group', 'phase1_sa_lifetime')), - (_('Phase 2 Parameters'), ('phase2_encryption', 'phase2_authentication', 'phase2_group', 'phase2_sa_lifetime')), + (_('Profile'), ( + 'protocol', 'ike_version', 'description', + )), + (_('Phase 1 Parameters'), ( + 'phase1_encryption', 'phase1_authentication', 'phase1_group', 'phase1_sa_lifetime', + )), + (_('Phase 2 Parameters'), ( + 'phase2_encryption', 'phase2_authentication', 'phase2_group', 'phase2_sa_lifetime', + 'phase2_sa_lifetime_data', + )), ) nullable_fields = ( - 'description', 'phase1_sa_lifetime', 'phase2_sa_lifetime', 'comments', + 'description', 'phase1_sa_lifetime', 'phase2_sa_lifetime', 'phase2_sa_lifetime_data', 'comments', ) diff --git a/netbox/vpn/forms/bulk_import.py b/netbox/vpn/forms/bulk_import.py index 61e9a49993b..db601b709ae 100644 --- a/netbox/vpn/forms/bulk_import.py +++ b/netbox/vpn/forms/bulk_import.py @@ -148,6 +148,6 @@ class Meta: model = IPSecProfile fields = ( 'name', 'protocol', 'ike_version', 'phase1_encryption', 'phase1_authentication', 'phase1_group', - 'phase1_sa_lifetime', 'phase1_encryption', 'phase1_authentication', 'phase1_group', 'phase1_sa_lifetime', - 'description', 'comments', 'tags', + 'phase1_sa_lifetime', 'phase2_encryption', 'phase2_authentication', 'phase2_group', 'phase2_sa_lifetime', + 'phase2_sa_lifetime_data', 'description', 'comments', 'tags', ) diff --git a/netbox/vpn/forms/filtersets.py b/netbox/vpn/forms/filtersets.py index 53a3bd63490..44ad79b7e3d 100644 --- a/netbox/vpn/forms/filtersets.py +++ b/netbox/vpn/forms/filtersets.py @@ -72,8 +72,13 @@ class IPSecProfileFilterForm(NetBoxModelFilterSetForm): fieldsets = ( (None, ('q', 'filter_id', 'tag')), (_('Profile'), ('protocol', 'ike_version')), - (_('Phase 1 Parameters'), ('phase1_encryption', 'phase1_authentication', 'phase1_group', 'phase1_sa_lifetime')), - (_('Phase 2 Parameters'), ('phase2_encryption', 'phase2_authentication', 'phase2_group', 'phase2_sa_lifetime')), + (_('Phase 1 Parameters'), ( + 'phase1_encryption', 'phase1_authentication', 'phase1_group', 'phase1_sa_lifetime', + )), + (_('Phase 2 Parameters'), ( + 'phase2_encryption', 'phase2_authentication', 'phase2_group', 'phase2_sa_lifetime', + 'phase2_sa_lifetime_data', + )), ) protocol = forms.MultipleChoiceField( label=_('Protocol'), @@ -130,4 +135,9 @@ class IPSecProfileFilterForm(NetBoxModelFilterSetForm): min_value=0, label=_('SA lifetime') ) + phase2_sa_lifetime_data = forms.IntegerField( + required=False, + min_value=0, + label=_('SA lifetime (data)') + ) tag = TagFilterField(model) diff --git a/netbox/vpn/forms/model_forms.py b/netbox/vpn/forms/model_forms.py index 9755bd538c6..175c27a471b 100644 --- a/netbox/vpn/forms/model_forms.py +++ b/netbox/vpn/forms/model_forms.py @@ -1,4 +1,3 @@ -from django import forms from django.utils.translation import gettext_lazy as _ from dcim.models import Interface @@ -89,9 +88,16 @@ class IPSecProfileForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - (_('Profile'), ('name', 'protocol', 'ike_version', 'description', 'tags')), - (_('Phase 1 Parameters'), ('phase1_encryption', 'phase1_authentication', 'phase1_group', 'phase1_sa_lifetime')), - (_('Phase 2 Parameters'), ('phase2_encryption', 'phase2_authentication', 'phase2_group', 'phase2_sa_lifetime')), + (_('Profile'), ( + 'name', 'protocol', 'ike_version', 'description', 'tags', + )), + (_('Phase 1 Parameters'), ( + 'phase1_encryption', 'phase1_authentication', 'phase1_group', 'phase1_sa_lifetime', + )), + (_('Phase 2 Parameters'), ( + 'phase2_encryption', 'phase2_authentication', 'phase2_group', 'phase2_sa_lifetime', + 'phase2_sa_lifetime_data', + )), ) class Meta: @@ -99,5 +105,5 @@ class Meta: fields = [ 'name', 'protocol', 'ike_version', 'phase1_encryption', 'phase1_authentication', 'phase1_group', 'phase1_sa_lifetime', 'phase2_encryption', 'phase2_authentication', 'phase2_group', 'phase2_sa_lifetime', - 'description', 'comments', 'tags', + 'phase2_sa_lifetime_data', 'description', 'comments', 'tags', ] diff --git a/netbox/vpn/migrations/0001_initial.py b/netbox/vpn/migrations/0001_initial.py index 0bb11185974..0a82eb34410 100644 --- a/netbox/vpn/migrations/0001_initial.py +++ b/netbox/vpn/migrations/0001_initial.py @@ -33,11 +33,12 @@ class Migration(migrations.Migration): ('phase1_encryption', models.CharField()), ('phase1_authentication', models.CharField()), ('phase1_group', models.PositiveSmallIntegerField()), - ('phase1_sa_lifetime', models.PositiveSmallIntegerField(blank=True, null=True)), + ('phase1_sa_lifetime', models.PositiveIntegerField(blank=True, null=True)), ('phase2_encryption', models.CharField()), ('phase2_authentication', models.CharField()), ('phase2_group', models.PositiveSmallIntegerField()), - ('phase2_sa_lifetime', models.PositiveSmallIntegerField(blank=True, null=True)), + ('phase2_sa_lifetime', models.PositiveIntegerField(blank=True, null=True)), + ('phase2_sa_lifetime_data', models.PositiveIntegerField(blank=True, null=True)), ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), ], options={ @@ -59,7 +60,7 @@ class Migration(migrations.Migration): ('status', models.CharField(default='active', max_length=50)), ('encapsulation', models.CharField(max_length=50)), ('preshared_key', models.TextField(blank=True)), - ('tunnel_id', models.PositiveBigIntegerField(blank=True)), + ('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')), diff --git a/netbox/vpn/migrations/0002_alter_ipsecprofile_phase1_sa_lifetime_and_more.py b/netbox/vpn/migrations/0002_alter_ipsecprofile_phase1_sa_lifetime_and_more.py deleted file mode 100644 index f07076c5014..00000000000 --- a/netbox/vpn/migrations/0002_alter_ipsecprofile_phase1_sa_lifetime_and_more.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 4.2.6 on 2023-11-08 16:04 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('vpn', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='ipsecprofile', - name='phase1_sa_lifetime', - field=models.PositiveIntegerField(blank=True, null=True), - ), - migrations.AlterField( - model_name='ipsecprofile', - name='phase2_sa_lifetime', - field=models.PositiveIntegerField(blank=True, null=True), - ), - ] diff --git a/netbox/vpn/migrations/0003_alter_tunnel_tunnel_id.py b/netbox/vpn/migrations/0003_alter_tunnel_tunnel_id.py deleted file mode 100644 index b02b4aa861b..00000000000 --- a/netbox/vpn/migrations/0003_alter_tunnel_tunnel_id.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.6 on 2023-11-08 16:06 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('vpn', '0002_alter_ipsecprofile_phase1_sa_lifetime_and_more'), - ] - - operations = [ - migrations.AlterField( - model_name='tunnel', - name='tunnel_id', - field=models.PositiveBigIntegerField(blank=True, null=True), - ), - ] diff --git a/netbox/vpn/models/crypto.py b/netbox/vpn/models/crypto.py index 8c817b296ed..059d8f2f461 100644 --- a/netbox/vpn/models/crypto.py +++ b/netbox/vpn/models/crypto.py @@ -62,16 +62,22 @@ class IPSecProfile(PrimaryModel): help_text=_('Diffie-Hellman group') ) phase2_sa_lifetime = models.PositiveIntegerField( - verbose_name=_('phase 2 SA lifetime'), + verbose_name=_('phase 2 SA lifetime (seconds)'), blank=True, null=True, - help_text=_('Security association lifetime (in seconds)') + help_text=_('Security association lifetime (seconds)') + ) + phase2_sa_lifetime_data = models.PositiveIntegerField( + verbose_name=_('phase 2 SA lifetime (KB)'), + blank=True, + null=True, + help_text=_('Security association lifetime (in kilobytes)') ) # TODO: Add PFS group? clone_fields = ( 'protocol', 'ike_version', 'phase1_encryption', 'phase1_authentication', 'phase1_group', 'phase1_sa_lifetime', - 'phase2_encryption', 'phase2_authentication', 'phase2_group', 'phase2_sa_lifetime', + 'phase2_encryption', 'phase2_authentication', 'phase2_group', 'phase2_sa_lifetime', 'phase2_sa_lifetime_data', ) class Meta: diff --git a/netbox/vpn/tables.py b/netbox/vpn/tables.py index 3d589abca59..5697e1dc20a 100644 --- a/netbox/vpn/tables.py +++ b/netbox/vpn/tables.py @@ -115,7 +115,7 @@ class Meta(NetBoxTable.Meta): model = IPSecProfile fields = ( 'pk', 'id', 'name', 'protocol', 'ike_version', 'phase1_encryption', 'phase1_authentication', 'phase1_group', - 'phase1_sa_lifetime', 'phase2_encryption', 'phase2_authentication', 'phase2_group', 'phase1_sa_lifetime', - 'description', 'comments', 'tags', 'created', 'last_updated', + 'phase1_sa_lifetime', 'phase2_encryption', 'phase2_authentication', 'phase2_group', 'phase2_sa_lifetime', + 'phase2_sa_lifetime_data', 'description', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'protocol', 'ike_version', 'description') From 255764c4c524872e03ec55697ce04df611d81d48 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 15 Nov 2023 08:55:37 -0500 Subject: [PATCH 04/27] Fleshed out IKE parameter choices --- netbox/vpn/choices.py | 90 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 73 insertions(+), 17 deletions(-) diff --git a/netbox/vpn/choices.py b/netbox/vpn/choices.py index 96d73633f4a..1ea8bcadb36 100644 --- a/netbox/vpn/choices.py +++ b/netbox/vpn/choices.py @@ -72,39 +72,95 @@ class IKEVersionChoices(ChoiceSet): class EncryptionChoices(ChoiceSet): - ENCRYPTION_AES128 = 'aes-128' - ENCRYPTION_AES192 = 'aes-192' - ENCRYPTION_AES256 = 'aes-256' - ENCRYPTION_3DES = '3des' + 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, 'AES (128-bit)'), - (ENCRYPTION_AES192, 'AES (192-bit)'), - (ENCRYPTION_AES256, 'AES (256-bit)'), + (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 AuthenticationChoices(ChoiceSet): - AUTH_SHA1 = 'SHA-1' - AUTH_MD5 = 'MD5' + 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_SHA1, 'SHA-1'), - (AUTH_MD5, 'MD5'), + (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): - # TODO: Add all the groups & annotate their attributes - GROUP_1 = 1 - GROUP_2 = 2 - GROUP_5 = 5 - GROUP_7 = 7 + # 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_7, _('Group {n}').format(n=7)), + (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)), ) From a4e8070c6d7d0b3013c09389944368ee5a0cfa9f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 15 Nov 2023 11:37:23 -0500 Subject: [PATCH 05/27] Add forms for creating tunnel terminations --- netbox/vpn/choices.py | 11 ++ netbox/vpn/forms/model_forms.py | 230 ++++++++++++++++++++++---- netbox/vpn/migrations/0001_initial.py | 2 +- netbox/vpn/models/tunnels.py | 9 +- netbox/vpn/views.py | 16 ++ 5 files changed, 238 insertions(+), 30 deletions(-) diff --git a/netbox/vpn/choices.py b/netbox/vpn/choices.py index 1ea8bcadb36..211dcb37247 100644 --- a/netbox/vpn/choices.py +++ b/netbox/vpn/choices.py @@ -35,6 +35,17 @@ class TunnelEncapsulationChoices(ChoiceSet): ] +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' diff --git a/netbox/vpn/forms/model_forms.py b/netbox/vpn/forms/model_forms.py index 175c27a471b..34976a62af2 100644 --- a/netbox/vpn/forms/model_forms.py +++ b/netbox/vpn/forms/model_forms.py @@ -1,24 +1,30 @@ +from django import forms from django.utils.translation import gettext_lazy as _ -from dcim.models import Interface +from dcim.models import Device, Interface from ipam.models import IPAddress from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from utilities.forms.fields import CommentField, DynamicModelChoiceField -from virtualization.models import VMInterface +from utilities.forms.widgets import HTMXSelect +from virtualization.models import VirtualMachine, VMInterface +from vpn.choices import * from vpn.models import * __all__ = ( 'IPSecProfileForm', + 'TunnelCreateForm', 'TunnelForm', 'TunnelTerminationForm', + 'TunnelTerminationCreateForm', ) class TunnelForm(TenancyForm, NetBoxModelForm): ipsec_profile = DynamicModelChoiceField( queryset=IPSecProfile.objects.all(), - label=_('IPSec Profile') + label=_('IPSec Profile'), + required=False ) comments = CommentField() @@ -36,52 +42,220 @@ class Meta: ] -class TunnelTerminationForm(NetBoxModelForm): - tunnel = DynamicModelChoiceField( - queryset=Tunnel.objects.all() +class TunnelCreateForm(TunnelForm): + # First termination + termination1_role = forms.ChoiceField( + choices=TunnelTerminationRoleChoices, + label=_('Role') ) - interface = DynamicModelChoiceField( - label=_('Interface'), + termination1_type = forms.ChoiceField( + choices=TunnelTerminationTypeChoices, + widget=HTMXSelect(), + label=_('Type') + ) + termination1_parent = DynamicModelChoiceField( + queryset=Device.objects.all(), + selector=True, + label=_('Device') + ) + termination1_interface = DynamicModelChoiceField( queryset=Interface.objects.all(), + label=_('Interface'), + query_params={ + 'device_id': '$termination1_parent', + } + ) + termination1_outside_ip = DynamicModelChoiceField( + queryset=IPAddress.objects.all(), + label=_('Outside IP'), required=False, - selector=True, + query_params={ + 'device_id': '$termination1_parent', + } ) - vminterface = DynamicModelChoiceField( - queryset=VMInterface.objects.all(), + + # Second termination + termination2_role = forms.ChoiceField( + choices=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_interface = DynamicModelChoiceField( + queryset=Interface.objects.all(), + required=False, label=_('Interface'), + query_params={ + 'device_id': '$termination2_parent', + } ) - outside_ip = DynamicModelChoiceField( + termination2_outside_ip = DynamicModelChoiceField( queryset=IPAddress.objects.all(), - selector=True, + required=False, label=_('Outside IP'), + query_params={ + 'device_id': '$termination2_parent', + } + ) + + fieldsets = ( + (_('Tunnel'), ('name', 'status', 'encapsulation', 'description', 'tunnel_id', 'tags')), + (_('Security'), ('ipsec_profile', 'preshared_key')), + (_('Tenancy'), ('tenant_group', 'tenant')), + (_('First Termination'), ( + 'termination1_role', 'termination1_type', 'termination1_parent', 'termination1_interface', + 'termination1_outside_ip', + )), + (_('Second Termination'), ( + 'termination2_role', 'termination2_type', 'termination2_parent', 'termination2_interface', + '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_interface'].queryset = VMInterface.objects.all() + self.fields['termination1_interface'].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_interface'].queryset = VMInterface.objects.all() + self.fields['termination2_interface'].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() + + # Check that all required parameters have been set for the second termination (if any) + termination2_required_parameters = ( + 'termination2_role', 'termination2_type', 'termination2_parent', 'termination2_interface', + ) + termination2_parameters = ( + *termination2_required_parameters, + 'termination2_outside_ip', + ) + if any([self.cleaned_data[param] for param in termination2_parameters]): + for param in termination2_required_parameters: + if not self.cleaned_data[param]: + raise forms.ValidationError({ + param: _("This parameter is required when defining a second termination.") + }) + + def save(self, *args, **kwargs): + instance = super().save(*args, **kwargs) + + # Create first termination + TunnelTermination.objects.create( + tunnel=instance, + role=self.cleaned_data['termination1_role'], + interface=self.cleaned_data['termination1_interface'], + outside_ip=self.cleaned_data['termination1_outside_ip'], + ) + + # Create second termination, if defined + if self.cleaned_data['termination2_role']: + TunnelTermination.objects.create( + tunnel=instance, + role=self.cleaned_data['termination2_role'], + interface=self.cleaned_data['termination2_interface'], + outside_ip=self.cleaned_data.get('termination1_outside_ip'), + ) + + return instance + + +class TunnelTerminationForm(NetBoxModelForm): + outside_ip = DynamicModelChoiceField( + queryset=IPAddress.objects.all(), + required=False, + label=_('Outside IP') ) class Meta: model = TunnelTermination fields = [ - 'tunnel', 'role', 'outside_ip', 'tags', + 'role', 'outside_ip', 'tags', ] - def __init__(self, *args, **kwargs): - # Initialize helper selectors - initial = kwargs.get('initial', {}).copy() - if instance := kwargs.get('instance'): - if type(instance.interface) is Interface: - initial['interface'] = instance.interface - elif type(instance.interface) is VMInterface: - initial['vminterface'] = instance.interface - kwargs['initial'] = initial +class TunnelTerminationCreateForm(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') + ) + interface = 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', + } + ) - super().__init__(*args, **kwargs) + fieldsets = ( + (None, ('tunnel', 'role', 'type', 'parent', 'interface', 'outside_ip', 'tags')), + ) - def clean(self): - super().clean() + class Meta: + model = TunnelTermination + fields = [ + 'tunnel', 'role', 'interface', 'outside_ip', 'tags', + ] + + def __init__(self, *args, initial=None, **kwargs): + super().__init__(*args, initial=initial, **kwargs) - # Handle interface assignment - self.instance.interface = self.cleaned_data['interface'] or self.cleaned_data['interface'] or None + if initial and initial.get('type') == TunnelTerminationTypeChoices.TYPE_VIRUTALMACHINE: + self.fields['parent'].label = _('Virtual Machine') + self.fields['parent'].queryset = VirtualMachine.objects.all() + self.fields['interface'].queryset = VMInterface.objects.all() + self.fields['interface'].widget.add_query_params({ + 'virtual_machine_id': '$parent', + }) + self.fields['outside_ip'].widget.add_query_params({ + 'virtual_machine_id': '$parent', + }) class IPSecProfileForm(NetBoxModelForm): diff --git a/netbox/vpn/migrations/0001_initial.py b/netbox/vpn/migrations/0001_initial.py index 0a82eb34410..0e79e9061c8 100644 --- a/netbox/vpn/migrations/0001_initial.py +++ b/netbox/vpn/migrations/0001_initial.py @@ -81,7 +81,7 @@ class Migration(migrations.Migration): ('role', models.CharField(default='peer', max_length=50)), ('interface_id', models.PositiveBigIntegerField(blank=True, null=True)), ('interface_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')), - ('outside_ip', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='tunnel_termination', to='ipam.ipaddress')), + ('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')), ], diff --git a/netbox/vpn/models/tunnels.py b/netbox/vpn/models/tunnels.py index b48a30a4ffd..f6bb77dfd65 100644 --- a/netbox/vpn/models/tunnels.py +++ b/netbox/vpn/models/tunnels.py @@ -101,7 +101,9 @@ class TunnelTermination(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ChangeLo outside_ip = models.OneToOneField( to='ipam.IPAddress', on_delete=models.PROTECT, - related_name='tunnel_termination' + related_name='tunnel_termination', + blank=True, + null=True ) class Meta: @@ -117,3 +119,8 @@ def get_absolute_url(self): def get_role_color(self): return TunnelTerminationRoleChoices.colors.get(self.role) + + def to_objectchange(self, action): + objectchange = super().to_objectchange(action) + objectchange.related_object = self.tunnel + return objectchange diff --git a/netbox/vpn/views.py b/netbox/vpn/views.py index 9ea4fd215fd..aa63d00daf3 100644 --- a/netbox/vpn/views.py +++ b/netbox/vpn/views.py @@ -28,6 +28,14 @@ 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): @@ -72,6 +80,14 @@ class TunnelTerminationEditView(generic.ObjectEditView): queryset = TunnelTermination.objects.all() form = forms.TunnelTerminationForm + def dispatch(self, request, *args, **kwargs): + + # If creating a new Tunnel, use the creation form + if 'pk' not in kwargs: + self.form = forms.TunnelTerminationCreateForm + + return super().dispatch(request, *args, **kwargs) + @register_model_view(TunnelTermination, 'delete') class TunnelTerminationDeleteView(generic.ObjectDeleteView): From bf4bc2344cda8b97612e3e716a9180cd3e6cf99d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 15 Nov 2023 11:47:46 -0500 Subject: [PATCH 06/27] Add columns to TunnelTerminationTable --- netbox/vpn/tables.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/netbox/vpn/tables.py b/netbox/vpn/tables.py index 5697e1dc20a..33eb6da85ec 100644 --- a/netbox/vpn/tables.py +++ b/netbox/vpn/tables.py @@ -1,4 +1,5 @@ import django_tables2 as tables +from django.contrib.contenttypes.fields import GenericRelation from django.utils.translation import gettext_lazy as _ from django_tables2.utils import Accessor @@ -55,10 +56,22 @@ class TunnelTerminationTable(TenancyColumnsMixin, NetBoxTable): role = columns.ChoiceFieldColumn( verbose_name=_('Role') ) + interface_parent = tables.Column( + accessor='interface__parent_object', + linkify=True, + orderable=False, + verbose_name=_('Host') + ) interface = tables.Column( verbose_name=_('Interface'), linkify=True ) + ip_addresses = tables.ManyToManyColumn( + accessor=tables.A('interface__ip_addresses'), + orderable=False, + linkify_item=True, + verbose_name=_('IP Addresses') + ) outside_ip = tables.Column( verbose_name=_('Outside IP'), linkify=True @@ -70,9 +83,10 @@ class TunnelTerminationTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = TunnelTermination fields = ( - 'pk', 'id', 'tunnel', 'role', 'interface', 'outside_ip', 'tags', 'created', 'last_updated', + 'pk', 'id', 'tunnel', 'role', 'interface_parent', 'interface', 'ip_addresses', 'outside_ip', 'tags', + 'created', 'last_updated', ) - default_columns = ('pk', 'tunnel', 'role', 'interface', 'outside_ip') + default_columns = ('pk', 'tunnel', 'role', 'interface_parent', 'interface', 'ip_addresses', 'outside_ip') class IPSecProfileTable(TenancyColumnsMixin, NetBoxTable): From a616f5f61e8d52ca4c081cf836ccad6b9ceeb6a8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 15 Nov 2023 13:03:36 -0500 Subject: [PATCH 07/27] Register GraphQL schema for vpn app --- netbox/netbox/graphql/schema.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/netbox/netbox/graphql/schema.py b/netbox/netbox/graphql/schema.py index 7224f3c38b6..021d6d902cc 100644 --- a/netbox/netbox/graphql/schema.py +++ b/netbox/netbox/graphql/schema.py @@ -9,6 +9,7 @@ from tenancy.graphql.schema import TenancyQuery from users.graphql.schema import UsersQuery from virtualization.graphql.schema import VirtualizationQuery +from vpn.graphql.schema import VPNQuery from wireless.graphql.schema import WirelessQuery @@ -21,6 +22,7 @@ class Query( IPAMQuery, TenancyQuery, VirtualizationQuery, + VPNQuery, WirelessQuery, *registry['plugins']['graphql_schemas'], # Append plugin schemas graphene.ObjectType From c7e33624084ed54eff524370c78e97dc0c4f6856 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 15 Nov 2023 14:49:11 -0500 Subject: [PATCH 08/27] Add tunnel column on interface tables --- netbox/dcim/models/device_components.py | 9 +++++++++ netbox/dcim/tables/devices.py | 13 ++++++++++--- netbox/virtualization/tables/virtualmachines.py | 5 +++-- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 94568459e80..558505e0c55 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -551,6 +551,11 @@ class BaseInterface(models.Model): blank=True, verbose_name=_('bridge interface') ) + tunnel_terminations = GenericRelation( + to='vpn.TunnelTermination', + content_type_field='interface_type', + object_id_field='interface_id' + ) class Meta: abstract = True @@ -567,6 +572,10 @@ def save(self, *args, **kwargs): return super().save(*args, **kwargs) + @property + def tunnel_termination(self): + return self.tunnel_terminations.first() + @property def count_ipaddresses(self): return self.ip_addresses.count() diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index b72c37daa4d..60e203697f4 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -584,6 +584,12 @@ class BaseInterfaceTable(NetBoxTable): orderable=False, verbose_name=_('L2VPN') ) + tunnel = tables.Column( + accessor=tables.A('tunnel_termination__tunnel'), + linkify=True, + orderable=False, + verbose_name=_('Tunnel') + ) untagged_vlan = tables.Column( verbose_name=_('Untagged VLAN'), linkify=True @@ -646,7 +652,8 @@ class Meta(DeviceComponentTable.Meta): 'speed', 'speed_formatted', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', - 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'inventory_items', 'created', 'last_updated', + 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'inventory_items', 'created', + 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') @@ -682,8 +689,8 @@ class Meta(DeviceComponentTable.Meta): 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', - 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'ip_addresses', 'fhrp_groups', - 'untagged_vlan', 'tagged_vlans', 'actions', + 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', + 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'actions', ) default_columns = ( 'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses', diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py index f8473df1e8a..193176ada5f 100644 --- a/netbox/virtualization/tables/virtualmachines.py +++ b/netbox/virtualization/tables/virtualmachines.py @@ -126,7 +126,8 @@ class Meta(NetBoxTable.Meta): 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,7 +150,7 @@ class Meta(NetBoxTable.Meta): 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 = { From 599d733df93ac1420776f5f950119f79ab111fe5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 15 Nov 2023 14:51:03 -0500 Subject: [PATCH 09/27] Tweak ordering; add unique constraint --- netbox/vpn/migrations/0001_initial.py | 12 ++++++++---- netbox/vpn/models/tunnels.py | 11 +++++++++-- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/netbox/vpn/migrations/0001_initial.py b/netbox/vpn/migrations/0001_initial.py index 0e79e9061c8..e0753249c36 100644 --- a/netbox/vpn/migrations/0001_initial.py +++ b/netbox/vpn/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.6 on 2023-11-07 21:49 +# Generated by Django 4.2.7 on 2023-11-15 19:50 from django.db import migrations, models import django.db.models.deletion @@ -11,10 +11,10 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('tenancy', '0011_contactassignment_tags'), - ('extras', '0099_cachedvalue_ordering'), ('contenttypes', '0002_remove_content_type_name'), + ('tenancy', '0011_contactassignment_tags'), ('ipam', '0067_ipaddress_index_host'), + ('extras', '0099_cachedvalue_ordering'), ] operations = [ @@ -88,7 +88,11 @@ class Migration(migrations.Migration): options={ 'verbose_name': 'tunnel termination', 'verbose_name_plural': 'tunnel terminations', - 'ordering': ('tunnel', 'pk'), + 'ordering': ('tunnel', 'role', 'pk'), }, ), + migrations.AddConstraint( + model_name='tunneltermination', + constraint=models.UniqueConstraint(fields=('interface_type', 'interface_id'), name='vpn_tunneltermination_interface', violation_error_message='An interface may be terminated to only one tunnel at a time.'), + ), ] diff --git a/netbox/vpn/models/tunnels.py b/netbox/vpn/models/tunnels.py index f6bb77dfd65..b07ac3ab441 100644 --- a/netbox/vpn/models/tunnels.py +++ b/netbox/vpn/models/tunnels.py @@ -1,4 +1,4 @@ -from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ @@ -107,7 +107,14 @@ class TunnelTermination(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ChangeLo ) class Meta: - ordering = ('tunnel', 'pk') + ordering = ('tunnel', 'role', 'pk') + constraints = ( + models.UniqueConstraint( + fields=('interface_type', 'interface_id'), + name='%(app_label)s_%(class)s_interface', + violation_error_message=_("An interface may be terminated to only one tunnel at a time.") + ), + ) verbose_name = _('tunnel termination') verbose_name_plural = _('tunnel terminations') From eba96771422cdcbeeece3ac0912962836c375887 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 15 Nov 2023 15:23:17 -0500 Subject: [PATCH 10/27] Workflow improvements --- netbox/dcim/tables/template_code.py | 10 ++++++++++ netbox/vpn/forms/model_forms.py | 6 ++++++ netbox/vpn/models/tunnels.py | 15 ++++++++++++++- 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index e0f38afefe3..a24f9ea6d34 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -359,6 +359,16 @@ {% endif %} +{% elif record.type == 'virtual' %} + {% if perms.vpn.add_tunnel and not record.tunnel_termination %} + + + + {% elif perms.vpn.delete_tunneltermination and record.tunnel_termination %} + + + + {% endif %} {% elif record.is_wired and perms.dcim.add_cable %} diff --git a/netbox/vpn/forms/model_forms.py b/netbox/vpn/forms/model_forms.py index 34976a62af2..cc8cb4b1f0a 100644 --- a/netbox/vpn/forms/model_forms.py +++ b/netbox/vpn/forms/model_forms.py @@ -257,6 +257,12 @@ def __init__(self, *args, initial=None, **kwargs): 'virtual_machine_id': '$parent', }) + def clean(self): + super().clean() + + # Assign the interface + self.instance.interface = self.cleaned_data['interface'] + class IPSecProfileForm(NetBoxModelForm): comments = CommentField() diff --git a/netbox/vpn/models/tunnels.py b/netbox/vpn/models/tunnels.py index b07ac3ab441..4f3bd411003 100644 --- a/netbox/vpn/models/tunnels.py +++ b/netbox/vpn/models/tunnels.py @@ -1,4 +1,5 @@ -from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.fields import GenericForeignKey +from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ @@ -127,6 +128,18 @@ def get_absolute_url(self): def get_role_color(self): return TunnelTerminationRoleChoices.colors.get(self.role) + def clean(self): + super().clean() + + # Check that the selected Interface is not already attached to a Tunnel + if self.interface.tunnel_termination: + raise ValidationError({ + 'interface': _("Interface {name} is already attached to a tunnel ({tunnel}).").format( + name=self.interface.name, + tunnel=self.interface.tunnel_termination.tunnel + ) + }) + def to_objectchange(self, action): objectchange = super().to_objectchange(action) objectchange.related_object = self.tunnel From 6d55f54afd56f57af4660e8d1b44d5f3fe9a3fab Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 20 Nov 2023 17:07:48 -0500 Subject: [PATCH 11/27] Refactor crypto models (WIP) --- netbox/netbox/navigation/menu.py | 4 + netbox/templates/vpn/ikepolicy.html | 45 +++++ netbox/templates/vpn/ikeproposal.html | 57 ++++++ netbox/templates/vpn/ipsecpolicy.html | 41 +++++ netbox/templates/vpn/ipsecproposal.html | 53 ++++++ netbox/vpn/api/nested_serializers.py | 44 +++++ netbox/vpn/api/serializers.py | 120 ++++++++++++- netbox/vpn/api/urls.py | 4 + netbox/vpn/api/views.py | 28 +++ netbox/vpn/choices.py | 50 ++++-- netbox/vpn/filtersets.py | 133 ++++++++++++-- netbox/vpn/forms/bulk_edit.py | 150 +++++++++++----- netbox/vpn/forms/bulk_import.py | 129 ++++++++++---- netbox/vpn/forms/filtersets.py | 148 ++++++++++------ netbox/vpn/forms/model_forms.py | 97 ++++++++-- netbox/vpn/migrations/0001_initial.py | 98 ----------- netbox/vpn/models/crypto.py | 224 +++++++++++++++++++----- netbox/vpn/models/tunnels.py | 4 - netbox/vpn/tables.py | 170 +++++++++++++++--- netbox/vpn/urls.py | 32 ++++ netbox/vpn/views.py | 180 +++++++++++++++++++ 21 files changed, 1462 insertions(+), 349 deletions(-) create mode 100644 netbox/templates/vpn/ikepolicy.html create mode 100644 netbox/templates/vpn/ikeproposal.html create mode 100644 netbox/templates/vpn/ipsecpolicy.html create mode 100644 netbox/templates/vpn/ipsecproposal.html delete mode 100644 netbox/vpn/migrations/0001_initial.py diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 904106ecf04..6a9f2fa0815 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -216,6 +216,10 @@ MenuGroup( label=_('Security'), items=( + get_model_item('vpn', 'ikeproposal', _('IKE Proposals')), + get_model_item('vpn', 'ikepolicy', _('IKE Policies')), + get_model_item('vpn', 'ipsecproposal', _('IPSec Proposals')), + get_model_item('vpn', 'ipsecpolicy', _('IPSec Policies')), get_model_item('vpn', 'ipsecprofile', _('IPSec Profiles')), ), ), diff --git a/netbox/templates/vpn/ikepolicy.html b/netbox/templates/vpn/ikepolicy.html new file mode 100644 index 00000000000..1bf49818bae --- /dev/null +++ b/netbox/templates/vpn/ikepolicy.html @@ -0,0 +1,45 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load i18n %} + +{% block content %} +
+
+
+
{% trans "IKE Policy" %}
+
+ + + + + + + + + + + + + + + + + +
{% trans "Name" %}{{ object.name }}
{% trans "IKE Version" %}{{ object.get_version_display }}
{% trans "Mode" %}{{ object.get_mode_display }}
{% trans "Description" %}{{ object.description|placeholder }}
+
+
+ {% plugin_left_page object %} +
+
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% plugin_right_page object %} +
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/vpn/ikeproposal.html b/netbox/templates/vpn/ikeproposal.html new file mode 100644 index 00000000000..6079f077146 --- /dev/null +++ b/netbox/templates/vpn/ikeproposal.html @@ -0,0 +1,57 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load i18n %} + +{% block content %} +
+
+
+
{% trans "IKE Proposal" %}
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Name" %}{{ object.name }}
{% trans "Authentication method" %}{{ object.get_authentication_method_display }}
{% trans "Encryption algorithm" %}{{ object.get_encryption_algorithm_display }}
{% trans "Authentication algorithm" %}{{ object.get_authentication_algorithm_display }}
{% trans "DH group" %}{{ object.get_group_display }}
{% trans "SA lifetime (seconds)" %}{{ object.sa_lifetime|placeholder }}
{% trans "Description" %}{{ object.description|placeholder }}
+
+
+ {% plugin_left_page object %} +
+
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% plugin_right_page object %} +
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/vpn/ipsecpolicy.html b/netbox/templates/vpn/ipsecpolicy.html new file mode 100644 index 00000000000..8b83438769a --- /dev/null +++ b/netbox/templates/vpn/ipsecpolicy.html @@ -0,0 +1,41 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load i18n %} + +{% block content %} +
+
+
+
{% trans "IPSec Policy" %}
+
+ + + + + + + + + + + + + +
{% trans "Name" %}{{ object.name }}
{% trans "PFS group" %}{{ object.get_pfs_group_display|placeholder }}
{% trans "Description" %}{{ object.description|placeholder }}
+
+
+ {% plugin_left_page object %} +
+
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% plugin_right_page object %} +
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/vpn/ipsecproposal.html b/netbox/templates/vpn/ipsecproposal.html new file mode 100644 index 00000000000..ad375f4e306 --- /dev/null +++ b/netbox/templates/vpn/ipsecproposal.html @@ -0,0 +1,53 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load i18n %} + +{% block content %} +
+
+
+
{% trans "IPSec Proposal" %}
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Name" %}{{ object.name }}
{% trans "Encryption algorithm" %}{{ object.get_encryption_algorithm_display }}
{% trans "Authentication algorithm" %}{{ object.get_authentication_algorithm_display }}
{% trans "SA lifetime (seconds)" %}{{ object.sa_lifetime_seconds|placeholder }}
{% trans "SA lifetime (KB)" %}{{ object.sa_lifetime_data|placeholder }}
{% trans "Description" %}{{ object.description|placeholder }}
+
+
+ {% plugin_left_page object %} +
+
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% plugin_right_page object %} +
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/vpn/api/nested_serializers.py b/netbox/vpn/api/nested_serializers.py index 7ab1654b942..9e2dee8ba56 100644 --- a/netbox/vpn/api/nested_serializers.py +++ b/netbox/vpn/api/nested_serializers.py @@ -4,7 +4,11 @@ from vpn import models __all__ = ( + 'NestedIKEPolicySerializer', + 'NestedIKEProposalSerializer', + 'NestedIPSecPolicySerializer', 'NestedIPSecProfileSerializer', + 'NestedIPSecProposalSerializer', 'NestedTunnelSerializer', 'NestedTunnelTerminationSerializer', ) @@ -30,6 +34,46 @@ class Meta: 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.IKEProposal + 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.IPSecProposal + fields = ('id', 'url', 'display', 'name') + + class NestedIPSecProfileSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField( view_name='vpn-api:ipsecprofile-detail' diff --git a/netbox/vpn/api/serializers.py b/netbox/vpn/api/serializers.py index a65305dfe75..0ebe28dba78 100644 --- a/netbox/vpn/api/serializers.py +++ b/netbox/vpn/api/serializers.py @@ -3,7 +3,7 @@ from rest_framework import serializers from ipam.api.nested_serializers import NestedIPAddressSerializer -from netbox.api.fields import ChoiceField, ContentTypeField +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 @@ -13,7 +13,11 @@ from .nested_serializers import * __all__ = ( + 'IKEPolicySerializer', + 'IKEProposalSerializer', + 'IPSecPolicySerializer', 'IPSecProfileSerializer', + 'IPSecProposalSerializer', 'TunnelSerializer', 'TunnelTerminationSerializer', ) @@ -41,8 +45,8 @@ class TunnelSerializer(NetBoxModelSerializer): class Meta: model = Tunnel fields = ( - 'id', 'url', 'display', 'name', 'status', 'encapsulation', 'ipsec_profile', 'tenant', 'preshared_key', - 'tunnel_id', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'status', 'encapsulation', 'ipsec_profile', 'tenant', 'tunnel_id', + 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ) @@ -79,30 +83,130 @@ def get_interface(self, obj): return serializer(obj.interface, 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', '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 + ) + authentication_algorithm = ChoiceField( + choices=AuthenticationAlgorithmChoices + ) + group = ChoiceField( + choices=DHGroupChoices + ) + 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', + 'certificate', '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 + ) + group = ChoiceField( + choices=DHGroupChoices + ) + + class Meta: + model = IPSecProposal + fields = ( + 'id', 'url', 'display', 'name', 'description', 'encryption_algorithm', 'authentication_algorithm', + 'sa_lifetime_data', 'sa_lifetime_seconds', '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 + ) + + class Meta: + model = IPSecPolicy + fields = ( + 'id', 'url', 'display', 'name', 'description', 'proposals', 'pfs_group', 'tags', 'custom_fields', 'created', + 'last_updated', + ) + + class IPSecProfileSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField( view_name='vpn-api:ipsecprofile-detail' ) protocol = ChoiceField( - choices=IPSecProtocolChoices + choices=IPSecModeChoices ) ike_version = ChoiceField( choices=IKEVersionChoices ) phase1_encryption = ChoiceField( - choices=EncryptionChoices + choices=EncryptionAlgorithmChoices ) phase1_authentication = ChoiceField( - choices=AuthenticationChoices + choices=AuthenticationAlgorithmChoices ) phase1_group = ChoiceField( choices=DHGroupChoices ) phase2_encryption = ChoiceField( - choices=EncryptionChoices + choices=EncryptionAlgorithmChoices ) phase2_authentication = ChoiceField( - choices=AuthenticationChoices + choices=AuthenticationAlgorithmChoices ) phase2_group = ChoiceField( choices=DHGroupChoices diff --git a/netbox/vpn/api/urls.py b/netbox/vpn/api/urls.py index 084514e35ba..f646174d507 100644 --- a/netbox/vpn/api/urls.py +++ b/netbox/vpn/api/urls.py @@ -3,6 +3,10 @@ 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('tunnels', views.TunnelViewSet) router.register('tunnel-terminations', views.TunnelTerminationViewSet) diff --git a/netbox/vpn/api/views.py b/netbox/vpn/api/views.py index 7d01c48cf17..960bcb1b4e1 100644 --- a/netbox/vpn/api/views.py +++ b/netbox/vpn/api/views.py @@ -7,7 +7,11 @@ from . import serializers __all__ = ( + 'IKEPolicyViewSet', + 'IKEProposalViewSet', + 'IPSecPolicyViewSet', 'IPSecProfileViewSet', + 'IPSecProposalViewSet', 'TunnelTerminationViewSet', 'TunnelViewSet', 'VPNRootView', @@ -40,6 +44,30 @@ class TunnelTerminationViewSet(NetBoxModelViewSet): 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 = IKEPolicy.objects.all() + serializer_class = serializers.IPSecPolicySerializer + filterset_class = filtersets.IPSecPolicyFilterSet + + class IPSecProfileViewSet(NetBoxModelViewSet): queryset = IPSecProfile.objects.all() serializer_class = serializers.IPSecProfileSerializer diff --git a/netbox/vpn/choices.py b/netbox/vpn/choices.py index 211dcb37247..a932c5055e8 100644 --- a/netbox/vpn/choices.py +++ b/netbox/vpn/choices.py @@ -59,19 +59,9 @@ class TunnelTerminationRoleChoices(ChoiceSet): # -# IKE +# Crypto # -class IPSecProtocolChoices(ChoiceSet): - PROTOCOL_ESP = 'esp' - PROTOCOL_AH = 'ah' - - CHOICES = ( - (PROTOCOL_ESP, 'ESP'), - (PROTOCOL_AH, 'AH'), - ) - - class IKEVersionChoices(ChoiceSet): VERSION_1 = 1 VERSION_2 = 2 @@ -82,7 +72,41 @@ class IKEVersionChoices(ChoiceSet): ) -class EncryptionChoices(ChoiceSet): +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' @@ -104,7 +128,7 @@ class EncryptionChoices(ChoiceSet): ) -class AuthenticationChoices(ChoiceSet): +class AuthenticationAlgorithmChoices(ChoiceSet): AUTH_HMAC_SHA1 = 'hmac-sha1' AUTH_HMAC_SHA256 = 'hmac-sha256' AUTH_HMAC_SHA384 = 'hmac-sha384' diff --git a/netbox/vpn/filtersets.py b/netbox/vpn/filtersets.py index 99dcde379f2..a417f4e320b 100644 --- a/netbox/vpn/filtersets.py +++ b/netbox/vpn/filtersets.py @@ -6,12 +6,17 @@ from ipam.models import IPAddress from netbox.filtersets import NetBoxModelFilterSet from tenancy.filtersets import TenancyFilterSet +from utilities.filters import ContentTypeFilter, MultiValueNumberFilter from virtualization.models import VMInterface from .choices import * from .models import * __all__ = ( + 'IKEPolicyFilterSet', + 'IKEProposalFilterSet', + 'IPSecPolicyFilterSet', 'IPSecProfileFilterSet', + 'IPSecProposalFilterSet', 'TunnelFilterSet', 'TunnelTerminationFilterSet', ) @@ -37,7 +42,7 @@ class TunnelFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class Meta: model = Tunnel - fields = ['id', 'name', 'preshared_key', 'tunnel_id'] + fields = ['id', 'name', 'tunnel_id'] def search(self, queryset, name, value): if not value.strip(): @@ -97,35 +102,129 @@ class Meta: fields = ['id'] -class IPSecProfileFilterSet(NetBoxModelFilterSet): - protocol = django_filters.MultipleChoiceFilter( - choices=IPSecProtocolChoices +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 ) - ike_version = django_filters.MultipleChoiceFilter( + + class Meta: + model = IKEProposal + fields = ['id', 'name', 'sa_lifetime'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) + ) + + +class IKEPolicyFilterSet(NetBoxModelFilterSet): + version = django_filters.MultipleChoiceFilter( choices=IKEVersionChoices ) - phase1_encryption = django_filters.MultipleChoiceFilter( - choices=EncryptionChoices + mode = django_filters.MultipleChoiceFilter( + choices=IKEModeChoices + ) + proposal_id = MultiValueNumberFilter( + field_name='proposals__id' + ) + proposals = ContentTypeFilter() + + class Meta: + model = IKEPolicy + fields = ['id', 'name'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) + ) + + +class IPSecProposalFilterSet(NetBoxModelFilterSet): + encryption_algorithm = django_filters.MultipleChoiceFilter( + choices=EncryptionAlgorithmChoices ) - phase1_authentication = django_filters.MultipleChoiceFilter( - choices=AuthenticationChoices + authentication_algorithm = django_filters.MultipleChoiceFilter( + choices=AuthenticationAlgorithmChoices ) - phase1_group = django_filters.MultipleChoiceFilter( + + class Meta: + model = IPSecProposal + fields = ['id', 'name', 'sa_lifetime_seconds', 'sa_lifetime_data'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) + ) + + +class IPSecPolicyFilterSet(NetBoxModelFilterSet): + pfs_group = django_filters.MultipleChoiceFilter( choices=DHGroupChoices ) - phase2_encryption = django_filters.MultipleChoiceFilter( - choices=EncryptionChoices + proposal_id = MultiValueNumberFilter( + field_name='proposals__id' ) - phase2_authentication = django_filters.MultipleChoiceFilter( - choices=AuthenticationChoices + proposals = ContentTypeFilter() + + class Meta: + model = IPSecPolicy + fields = ['id', 'name'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) + ) + + +class IPSecProfileFilterSet(NetBoxModelFilterSet): + mode = django_filters.MultipleChoiceFilter( + choices=IPSecModeChoices ) - phase2_group = django_filters.MultipleChoiceFilter( - choices=DHGroupChoices + 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', 'phase1_sa_lifetime', 'phase2_sa_lifetime', 'phase2_sa_lifetime_data'] + fields = ['id', 'name'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/vpn/forms/bulk_edit.py b/netbox/vpn/forms/bulk_edit.py index 6969235f7bb..d645ca02b44 100644 --- a/netbox/vpn/forms/bulk_edit.py +++ b/netbox/vpn/forms/bulk_edit.py @@ -9,7 +9,11 @@ from vpn.models import * __all__ = ( + 'IKEPolicyBulkEditForm', + 'IKEProposalBulkEditForm', + 'IPSecPolicyBulkEditForm', 'IPSecProfileBulkEditForm', + 'IPSecProposalBulkEditForm', 'TunnelBulkEditForm', 'TunnelTerminationBulkEditForm', ) @@ -31,10 +35,6 @@ class TunnelBulkEditForm(NetBoxModelBulkEditForm): label=_('IPSec profile'), required=False ) - preshared_key = forms.CharField( - label=_('Pre-shared key'), - required=False - ) tenant = DynamicModelChoiceField( label=_('Tenant'), queryset=Tenant.objects.all(), @@ -54,11 +54,11 @@ class TunnelBulkEditForm(NetBoxModelBulkEditForm): model = Tunnel fieldsets = ( (_('Tunnel'), ('status', 'encapsulation', 'tunnel_id', 'description')), - (_('Security'), ('ipsec_profile', 'preshared_key')), + (_('Security'), ('ipsec_profile',)), (_('Tenancy'), ('tenant',)), ) nullable_fields = ( - 'ipsec_profile', 'preshared_key', 'tunnel_id', 'tenant', 'description', 'comments', + 'ipsec_profile', 'tunnel_id', 'tenant', 'description', 'comments', ) @@ -75,59 +75,128 @@ class TunnelTerminationBulkEditForm(NetBoxModelBulkEditForm): ) -class IPSecProfileBulkEditForm(NetBoxModelBulkEditForm): - protocol = forms.ChoiceField( - label=_('Protocol'), - choices=add_blank_choice(IPSecProtocolChoices), +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( required=False ) - ike_version = forms.ChoiceField( - label=_('IKE version'), + + model = IKEProposal + fieldsets = ( + (None, ('name', 'description')), + (_('Parameters'), ( + 'authentication_method', 'encryption_algorithm', 'authentication_algorithm', 'group', 'sa_lifetime', + )), + ) + nullable_fields = ( + 'description', 'sa_lifetime', 'comments', + ) + + +class IKEPolicyBulkEditForm(NetBoxModelBulkEditForm): + version = forms.ChoiceField( + label=_('Version'), choices=add_blank_choice(IKEVersionChoices), required=False ) - description = forms.CharField( - label=_('Description'), - max_length=200, + mode = forms.ChoiceField( + label=_('Mode'), + choices=add_blank_choice(IKEModeChoices), required=False ) - phase1_encryption = forms.ChoiceField( - label=_('Encryption'), - choices=add_blank_choice(EncryptionChoices), + preshared_key = forms.CharField( + label=_('Pre-shared key'), required=False ) - phase1_authentication = forms.ChoiceField( - label=_('Authentication'), - choices=add_blank_choice(AuthenticationChoices), + certificate = forms.CharField( + label=_('Certificate'), required=False ) - phase1_group = forms.ChoiceField( - label=_('Group'), - choices=add_blank_choice(DHGroupChoices), + + model = IKEPolicy + fieldsets = ( + (None, ('name', 'description')), + (_('Parameters'), ( + 'version', 'mode', 'preshared_key', 'certificate', + )), + ) + nullable_fields = ( + 'description', 'preshared_key', 'certificate', 'comments', + ) + + +class IPSecProposalBulkEditForm(NetBoxModelBulkEditForm): + encryption_algorithm = forms.ChoiceField( + label=_('Encryption algorithm'), + choices=add_blank_choice(EncryptionAlgorithmChoices), required=False ) - phase1_sa_lifetime = forms.IntegerField( + authentication_algorithm = forms.ChoiceField( + label=_('Authentication algorithm'), + choices=add_blank_choice(AuthenticationAlgorithmChoices), required=False ) - phase2_encryption = forms.ChoiceField( - label=_('Encryption'), - choices=add_blank_choice(EncryptionChoices), + sa_lifetime_seconds = forms.IntegerField( required=False ) - phase2_authentication = forms.ChoiceField( - label=_('Authentication'), - choices=add_blank_choice(AuthenticationChoices), + sa_lifetime_data = forms.IntegerField( required=False ) - phase2_group = forms.ChoiceField( - label=_('Group'), + + model = IPSecProposal + fieldsets = ( + (None, ('name', 'description')), + (_('Parameters'), ( + 'encryption_algorithm', 'authentication_algorithm', 'sa_lifetime_seconds', 'sa_lifetime_data', + )), + ) + nullable_fields = ( + 'description', 'sa_lifetime_seconds', 'sa_lifetime_data', 'comments', + ) + + +class IPSecPolicyBulkEditForm(NetBoxModelBulkEditForm): + pfs_group = forms.ChoiceField( + label=_('PFS group'), choices=add_blank_choice(DHGroupChoices), required=False ) - phase2_sa_lifetime = forms.IntegerField( - required=False + + model = IPSecPolicy + fieldsets = ( + (None, ('name', 'description')), + (_('Parameters'), ( + 'pfs_group', + )), + ) + nullable_fields = ( + 'description', 'pfs_group', 'comments', ) - phase2_sa_lifetime_data = forms.IntegerField( + + +class IPSecProfileBulkEditForm(NetBoxModelBulkEditForm): + description = forms.CharField( + label=_('Description'), + max_length=200, required=False ) comments = CommentField() @@ -137,14 +206,7 @@ class IPSecProfileBulkEditForm(NetBoxModelBulkEditForm): (_('Profile'), ( 'protocol', 'ike_version', 'description', )), - (_('Phase 1 Parameters'), ( - 'phase1_encryption', 'phase1_authentication', 'phase1_group', 'phase1_sa_lifetime', - )), - (_('Phase 2 Parameters'), ( - 'phase2_encryption', 'phase2_authentication', 'phase2_group', 'phase2_sa_lifetime', - 'phase2_sa_lifetime_data', - )), ) nullable_fields = ( - 'description', 'phase1_sa_lifetime', 'phase2_sa_lifetime', 'phase2_sa_lifetime_data', 'comments', + 'description', 'comments', ) diff --git a/netbox/vpn/forms/bulk_import.py b/netbox/vpn/forms/bulk_import.py index db601b709ae..1b19af25f37 100644 --- a/netbox/vpn/forms/bulk_import.py +++ b/netbox/vpn/forms/bulk_import.py @@ -10,7 +10,11 @@ from vpn.models import * __all__ = ( + 'IKEPolicyImportForm', + 'IKEProposalImportForm', + 'IPSecPolicyImportForm', 'IPSecProfileImportForm', + 'IPSecProposalImportForm', 'TunnelImportForm', 'TunnelTerminationImportForm', ) @@ -43,8 +47,8 @@ class TunnelImportForm(NetBoxModelImportForm): class Meta: model = Tunnel fields = ( - 'name', 'status', 'encapsulation', 'ipsec_profile', 'tenant', 'preshared_key', 'tunnel_id', 'description', - 'comments', 'tags', + 'name', 'status', 'encapsulation', 'ipsec_profile', 'tenant', 'tunnel_id', 'description', 'comments', + 'tags', ) @@ -108,46 +112,109 @@ def __init__(self, data=None, *args, **kwargs): ) -class IPSecProfileImportForm(NetBoxModelImportForm): - protocol = CSVChoiceField( - label=_('Protocol'), - choices=IPSecProtocolChoices, - help_text=_('IPSec protocol') - ) - ike_version = CSVChoiceField( - label=_('IKE version'), - choices=IKEVersionChoices, - help_text=_('IKE version') +class IKEProposalImportForm(NetBoxModelImportForm): + authentication_method = CSVChoiceField( + label=_('Authentication method'), + choices=AuthenticationMethodChoices ) - phase1_encryption = CSVChoiceField( - label=_('Phase 1 Encryption'), - choices=EncryptionChoices + encryption_algorithm = CSVChoiceField( + label=_('Encryption algorithm'), + choices=EncryptionAlgorithmChoices ) - phase1_authentication = CSVChoiceField( - label=_('Phase 1 Authentication'), - choices=AuthenticationChoices + authentication_algorithmn = CSVChoiceField( + label=_('Authentication algorithm'), + choices=AuthenticationAlgorithmChoices ) - phase1_group = CSVChoiceField( - label=_('Phase 1 Group'), + group = CSVChoiceField( + label=_('Group'), choices=DHGroupChoices ) - phase2_encryption = CSVChoiceField( - label=_('Phase 2 Encryption'), - choices=EncryptionChoices + + class Meta: + model = IKEProposal + fields = ( + 'name', 'description', 'authentication_method', 'encryption_algorithm', 'authentication_algorithmn', + 'group', 'sa_lifetime', 'tags', + ) + + +class IKEPolicyImportForm(NetBoxModelImportForm): + version = CSVChoiceField( + label=_('Version'), + choices=IKEVersionChoices + ) + mode = CSVChoiceField( + label=_('Mode'), + choices=IKEModeChoices ) - phase2_authentication = CSVChoiceField( - label=_('Phase 2 Authentication'), - choices=AuthenticationChoices + # TODO: M2M field for proposals + + class Meta: + model = IKEPolicy + fields = ( + 'name', 'description', 'version', 'mode', 'proposals', 'preshared_key', 'certificate', 'tags', + ) + + +class IPSecProposalImportForm(NetBoxModelImportForm): + authentication_method = CSVChoiceField( + label=_('Authentication method'), + choices=AuthenticationMethodChoices + ) + encryption_algorithm = CSVChoiceField( + label=_('Encryption algorithm'), + choices=EncryptionAlgorithmChoices + ) + authentication_algorithmn = CSVChoiceField( + label=_('Authentication algorithm'), + choices=AuthenticationAlgorithmChoices ) - phase2_group = CSVChoiceField( - label=_('Phase 2 Group'), + group = CSVChoiceField( + label=_('Group'), choices=DHGroupChoices ) + class Meta: + model = IPSecProposal + fields = ( + 'name', 'description', 'encryption_algorithm', 'authentication_algorithmn', 'sa_lifetime_seconds', + 'sa_lifetime_data', 'tags', + ) + + +class IPSecPolicyImportForm(NetBoxModelImportForm): + pfs_group = CSVChoiceField( + label=_('PFS group'), + choices=DHGroupChoices + ) + # TODO: M2M field for proposals + + class Meta: + model = IPSecPolicy + fields = ( + 'name', 'description', 'proposals', 'pfs_group', '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', 'protocol', 'ike_version', 'phase1_encryption', 'phase1_authentication', 'phase1_group', - 'phase1_sa_lifetime', 'phase2_encryption', 'phase2_authentication', 'phase2_group', 'phase2_sa_lifetime', - 'phase2_sa_lifetime_data', 'description', 'comments', 'tags', + 'name', 'ike_policy', 'ipsec_policy', 'description', 'comments', 'tags', ) diff --git a/netbox/vpn/forms/filtersets.py b/netbox/vpn/forms/filtersets.py index 44ad79b7e3d..98a62685758 100644 --- a/netbox/vpn/forms/filtersets.py +++ b/netbox/vpn/forms/filtersets.py @@ -8,7 +8,11 @@ from vpn.models import * __all__ = ( + 'IKEPolicyFilterForm', + 'IKEProposalFilterForm', + 'IPSecPolicyFilterForm', 'IPSecProfileFilterForm', + 'IPSecProposalFilterForm', 'TunnelFilterForm', 'TunnelTerminationFilterForm', ) @@ -19,7 +23,7 @@ class TunnelFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): fieldsets = ( (None, ('q', 'filter_id', 'tag')), (_('Tunnel'), ('status', 'encapsulation', 'tunnel_id')), - (_('Security'), ('ipsec_profile_id', 'preshared_key')), + (_('Security'), ('ipsec_profile_id',)), (_('Tenancy'), ('tenant_group_id', 'tenant_id')), ) status = forms.MultipleChoiceField( @@ -37,10 +41,6 @@ class TunnelFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): required=False, label=_('IPSec profile') ) - preshared_key = forms.CharField( - required=False, - label=_('Pre-shared key') - ) tunnel_id = forms.IntegerField( required=False, label=_('Tunnel ID') @@ -67,77 +67,123 @@ class TunnelTerminationFilterForm(NetBoxModelFilterSetForm): tag = TagFilterField(model) -class IPSecProfileFilterForm(NetBoxModelFilterSetForm): - model = IPSecProfile +class IKEProposalFilterForm(NetBoxModelFilterSetForm): + model = IKEProposal fieldsets = ( (None, ('q', 'filter_id', 'tag')), - (_('Profile'), ('protocol', 'ike_version')), - (_('Phase 1 Parameters'), ( - 'phase1_encryption', 'phase1_authentication', 'phase1_group', 'phase1_sa_lifetime', - )), - (_('Phase 2 Parameters'), ( - 'phase2_encryption', 'phase2_authentication', 'phase2_group', 'phase2_sa_lifetime', - 'phase2_sa_lifetime_data', - )), + (_('Parameters'), ('authentication_method', 'encryption_algorithm', 'authentication_algorithm', 'group')), ) - protocol = forms.MultipleChoiceField( - label=_('Protocol'), - choices=IPSecProtocolChoices, + authentication_method = forms.MultipleChoiceField( + label=_('Authentication method'), + choices=AuthenticationMethodChoices, required=False ) - ike_version = forms.MultipleChoiceField( - label=_('IKE version'), - choices=IKEVersionChoices, + encryption_algorithm = forms.MultipleChoiceField( + label=_('Encryption algorithm'), + choices=EncryptionAlgorithmChoices, required=False ) - ipsec_profile_id = DynamicModelMultipleChoiceField( - queryset=IPSecProfile.objects.all(), - required=False, - label=_('IPSec profile') + authentication_algorithm = forms.MultipleChoiceField( + label=_('Authentication algorithm'), + choices=AuthenticationAlgorithmChoices, + required=False ) - phase1_encryption = forms.MultipleChoiceField( - label=_('Encryption'), - choices=EncryptionChoices, + group = forms.MultipleChoiceField( + label=_('Group'), + choices=DHGroupChoices, required=False ) - phase1_authentication = forms.MultipleChoiceField( - label=_('Authentication'), - choices=AuthenticationChoices, + 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 ) - phase1_group = forms.MultipleChoiceField( - label=_('Group'), - choices=DHGroupChoices, + mode = forms.MultipleChoiceField( + label=_('Mode'), + choices=IKEModeChoices, required=False ) - phase1_sa_lifetime = forms.IntegerField( + proposal_id = DynamicModelMultipleChoiceField( + queryset=IKEProposal.objects.all(), required=False, - min_value=0, - label=_('SA lifetime') + label=_('Proposal') + ) + tag = TagFilterField(model) + + +class IPSecProposalFilterForm(NetBoxModelFilterSetForm): + model = IPSecProposal + fieldsets = ( + (None, ('q', 'filter_id', 'tag')), + (_('Parameters'), ('encryption_algorithm', 'authentication_algorithm')), ) - phase2_encryption = forms.MultipleChoiceField( - label=_('Encryption'), - choices=EncryptionChoices, + encryption_algorithm = forms.MultipleChoiceField( + label=_('Encryption algorithm'), + choices=EncryptionAlgorithmChoices, required=False ) - phase2_authentication = forms.MultipleChoiceField( - label=_('Authentication'), - choices=AuthenticationChoices, + authentication_algorithm = forms.MultipleChoiceField( + label=_('Authentication algorithm'), + choices=AuthenticationAlgorithmChoices, required=False ) - phase2_group = forms.MultipleChoiceField( - label=_('Group'), + tag = TagFilterField(model) + + +class IPSecPolicyFilterForm(NetBoxModelFilterSetForm): + model = IPSecPolicy + fieldsets = ( + (None, ('q', 'filter_id', 'tag')), + (_('Parameters'), ('proposal', 'pfs_group')), + ) + proposal_id = DynamicModelMultipleChoiceField( + queryset=IKEProposal.objects.all(), + required=False, + label=_('Proposal') + ) + pfs_group = forms.MultipleChoiceField( + label=_('Mode'), choices=DHGroupChoices, required=False ) - phase2_sa_lifetime = forms.IntegerField( + tag = TagFilterField(model) + + +class IPSecProfileFilterForm(NetBoxModelFilterSetForm): + model = IPSecProfile + fieldsets = ( + (None, ('q', 'filter_id', 'tag')), + (_('Profile'), ('protocol', 'ike_version')), + (_('Phase 1 Parameters'), ( + 'phase1_encryption', 'phase1_authentication', 'phase1_group', 'phase1_sa_lifetime', + )), + (_('Phase 2 Parameters'), ( + 'phase2_encryption', 'phase2_authentication', 'phase2_group', 'phase2_sa_lifetime', + 'phase2_sa_lifetime_data', + )), + ) + mode = forms.MultipleChoiceField( + label=_('Mode'), + choices=IPSecModeChoices, + required=False + ) + ike_policy_id = DynamicModelMultipleChoiceField( + queryset=IKEPolicy.objects.all(), required=False, - min_value=0, - label=_('SA lifetime') + label=_('IKE policy') ) - phase2_sa_lifetime_data = forms.IntegerField( + ipsec_policy_id = DynamicModelMultipleChoiceField( + queryset=IPSecPolicy.objects.all(), required=False, - min_value=0, - label=_('SA lifetime (data)') + label=_('IPSec policy') ) tag = TagFilterField(model) diff --git a/netbox/vpn/forms/model_forms.py b/netbox/vpn/forms/model_forms.py index cc8cb4b1f0a..6f289d26e69 100644 --- a/netbox/vpn/forms/model_forms.py +++ b/netbox/vpn/forms/model_forms.py @@ -5,14 +5,18 @@ from ipam.models import IPAddress from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm -from utilities.forms.fields import CommentField, DynamicModelChoiceField +from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField 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', 'TunnelCreateForm', 'TunnelForm', 'TunnelTerminationForm', @@ -30,15 +34,15 @@ class TunnelForm(TenancyForm, NetBoxModelForm): fieldsets = ( (_('Tunnel'), ('name', 'status', 'encapsulation', 'description', 'tunnel_id', 'tags')), - (_('Security'), ('ipsec_profile', 'preshared_key')), + (_('Security'), ('ipsec_profile',)), (_('Tenancy'), ('tenant_group', 'tenant')), ) class Meta: model = Tunnel fields = [ - 'name', 'status', 'encapsulation', 'description', 'tunnel_id', 'ipsec_profile', 'preshared_key', - 'tenant_group', 'tenant', 'comments', 'tags', + 'name', 'status', 'encapsulation', 'description', 'tunnel_id', 'ipsec_profile', 'tenant_group', 'tenant', + 'comments', 'tags', ] @@ -111,7 +115,7 @@ class TunnelCreateForm(TunnelForm): fieldsets = ( (_('Tunnel'), ('name', 'status', 'encapsulation', 'description', 'tunnel_id', 'tags')), - (_('Security'), ('ipsec_profile', 'preshared_key')), + (_('Security'), ('ipsec_profile',)), (_('Tenancy'), ('tenant_group', 'tenant')), (_('First Termination'), ( 'termination1_role', 'termination1_type', 'termination1_parent', 'termination1_interface', @@ -264,26 +268,87 @@ def clean(self): self.instance.interface = self.cleaned_data['interface'] +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', 'tags', + ] + + +class IKEPolicyForm(NetBoxModelForm): + proposals = DynamicModelMultipleChoiceField( + queryset=IKEProposal.objects.all() + ) + + fieldsets = ( + (_('Policy'), ('name', 'description', 'tags')), + (_('Parameters'), ('version', 'mode', 'proposals')), + (_('Authentication'), ('preshared_key', 'certificate')), + ) + + class Meta: + model = IKEPolicy + fields = [ + 'name', 'description', 'version', 'mode', 'proposals', 'preshared_key', 'certificate', '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', 'tags', + ] + + +class IPSecPolicyForm(NetBoxModelForm): + proposals = DynamicModelMultipleChoiceField( + queryset=IPSecProposal.objects.all() + ) + + fieldsets = ( + (_('Policy'), ('name', 'description', 'tags')), + (_('Parameters'), ('proposals', 'pfs_group')), + ) + + class Meta: + model = IPSecPolicy + fields = [ + 'name', 'description', 'proposals', 'pfs_group', 'tags', + ] + + class IPSecProfileForm(NetBoxModelForm): comments = CommentField() fieldsets = ( (_('Profile'), ( - 'name', 'protocol', 'ike_version', 'description', 'tags', - )), - (_('Phase 1 Parameters'), ( - 'phase1_encryption', 'phase1_authentication', 'phase1_group', 'phase1_sa_lifetime', - )), - (_('Phase 2 Parameters'), ( - 'phase2_encryption', 'phase2_authentication', 'phase2_group', 'phase2_sa_lifetime', - 'phase2_sa_lifetime_data', + 'name', 'mode', 'description', 'tags', )), + (_('Policies'), ('ipsec_policy', 'description', 'tags')), ) class Meta: model = IPSecProfile fields = [ - 'name', 'protocol', 'ike_version', 'phase1_encryption', 'phase1_authentication', 'phase1_group', - 'phase1_sa_lifetime', 'phase2_encryption', 'phase2_authentication', 'phase2_group', 'phase2_sa_lifetime', - 'phase2_sa_lifetime_data', 'description', 'comments', 'tags', + 'name', 'description', 'mode', 'ipsec_policy', 'description', 'comments', 'tags', ] diff --git a/netbox/vpn/migrations/0001_initial.py b/netbox/vpn/migrations/0001_initial.py deleted file mode 100644 index e0753249c36..00000000000 --- a/netbox/vpn/migrations/0001_initial.py +++ /dev/null @@ -1,98 +0,0 @@ -# Generated by Django 4.2.7 on 2023-11-15 19:50 - -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'), - ('tenancy', '0011_contactassignment_tags'), - ('ipam', '0067_ipaddress_index_host'), - ('extras', '0099_cachedvalue_ordering'), - ] - - operations = [ - 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)), - ('protocol', models.CharField()), - ('ike_version', models.PositiveSmallIntegerField(default=2)), - ('phase1_encryption', models.CharField()), - ('phase1_authentication', models.CharField()), - ('phase1_group', models.PositiveSmallIntegerField()), - ('phase1_sa_lifetime', models.PositiveIntegerField(blank=True, null=True)), - ('phase2_encryption', models.CharField()), - ('phase2_authentication', models.CharField()), - ('phase2_group', models.PositiveSmallIntegerField()), - ('phase2_sa_lifetime', models.PositiveIntegerField(blank=True, null=True)), - ('phase2_sa_lifetime_data', models.PositiveIntegerField(blank=True, null=True)), - ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), - ], - options={ - 'verbose_name': 'tunnel', - 'verbose_name_plural': 'tunnels', - 'ordering': ('name',), - }, - ), - 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)), - ('encapsulation', models.CharField(max_length=50)), - ('preshared_key', models.TextField(blank=True)), - ('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.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)), - ('interface_id', models.PositiveBigIntegerField(blank=True, null=True)), - ('interface_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.AddConstraint( - model_name='tunneltermination', - constraint=models.UniqueConstraint(fields=('interface_type', 'interface_id'), name='vpn_tunneltermination_interface', violation_error_message='An interface may be terminated to only one tunnel at a time.'), - ), - ] diff --git a/netbox/vpn/models/crypto.py b/netbox/vpn/models/crypto.py index 059d8f2f461..dacd40f81ac 100644 --- a/netbox/vpn/models/crypto.py +++ b/netbox/vpn/models/crypto.py @@ -2,88 +2,228 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from netbox.models import PrimaryModel +from netbox.models import NetBoxModel, PrimaryModel from vpn.choices import * __all__ = ( + 'IKEPolicy', + 'IKEProposal', + 'IPSecPolicy', 'IPSecProfile', + 'IPSecProposal', ) -class IPSecProfile(PrimaryModel): +# +# IKE +# + +class IKEProposal(NetBoxModel): name = models.CharField( verbose_name=_('name'), max_length=100, unique=True ) - protocol = models.CharField( - verbose_name=_('protocol'), - choices=IPSecProtocolChoices + description = models.CharField( + verbose_name=_('description'), + max_length=200, + blank=True ) - ike_version = models.PositiveSmallIntegerField( - verbose_name=_('IKE version'), - choices=IKEVersionChoices, - default=IKEVersionChoices.VERSION_2 + authentication_method = models.CharField( + verbose_name=('authentication method'), + choices=AuthenticationMethodChoices ) - - # Phase 1 parameters - phase1_encryption = models.CharField( - verbose_name=_('phase 1 encryption'), - choices=EncryptionChoices + encryption_algorithm = models.CharField( + verbose_name=_('encryption algorithm'), + choices=EncryptionAlgorithmChoices ) - phase1_authentication = models.CharField( - verbose_name=_('phase 1 authentication'), - choices=AuthenticationChoices + authentication_algorithm = models.CharField( + verbose_name=_('authentication algorithm'), + choices=AuthenticationAlgorithmChoices ) - phase1_group = models.PositiveSmallIntegerField( - verbose_name=_('phase 1 group'), + group = models.PositiveSmallIntegerField( + verbose_name=_('group'), choices=DHGroupChoices, - help_text=_('Diffie-Hellman group') + help_text=_('Diffie-Hellman group ID') ) - phase1_sa_lifetime = models.PositiveIntegerField( - verbose_name=_('phase 1 SA lifetime'), + sa_lifetime = models.PositiveIntegerField( + verbose_name=_('SA lifetime'), blank=True, null=True, help_text=_('Security association lifetime (in seconds)') ) - # Phase 2 parameters - phase2_encryption = models.CharField( - verbose_name=_('phase 2 encryption'), - choices=EncryptionChoices + 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(NetBoxModel): + name = models.CharField( + verbose_name=_('name'), + max_length=100, + unique=True ) - phase2_authentication = models.CharField( - verbose_name=_('phase 2 authentication'), - choices=AuthenticationChoices + description = models.CharField( + verbose_name=_('description'), + max_length=200, + blank=True ) - phase2_group = models.PositiveSmallIntegerField( - verbose_name=_('phase 2 group'), - choices=DHGroupChoices, - help_text=_('Diffie-Hellman group') + 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 + ) + certificate = models.TextField( + verbose_name=_('certificate'), + blank=True + ) + + 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:ikeprofile', args=[self.pk]) + + +# +# IPSec +# + +class IPSecProposal(NetBoxModel): + name = models.CharField( + verbose_name=_('name'), + max_length=100, + unique=True + ) + description = models.CharField( + verbose_name=_('description'), + max_length=200, + blank=True ) - phase2_sa_lifetime = models.PositiveIntegerField( - verbose_name=_('phase 2 SA lifetime (seconds)'), + encryption_algorithm = models.CharField( + verbose_name=_('encryption'), + choices=EncryptionAlgorithmChoices + ) + authentication_algorithm = models.CharField( + verbose_name=_('authentication'), + choices=AuthenticationAlgorithmChoices + ) + sa_lifetime_seconds = models.PositiveIntegerField( + verbose_name=_('SA lifetime (seconds)'), blank=True, null=True, help_text=_('Security association lifetime (seconds)') ) - phase2_sa_lifetime_data = models.PositiveIntegerField( - verbose_name=_('phase 2 SA lifetime (KB)'), + sa_lifetime_data = models.PositiveIntegerField( + verbose_name=_('SA lifetime (KB)'), blank=True, null=True, help_text=_('Security association lifetime (in kilobytes)') ) - # TODO: Add PFS group? + + 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]) + + +class IPSecPolicy(NetBoxModel): + name = models.CharField( + verbose_name=_('name'), + max_length=100, + unique=True + ) + description = models.CharField( + verbose_name=_('description'), + max_length=200, + blank=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') + ) + + 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 = ( - 'protocol', 'ike_version', 'phase1_encryption', 'phase1_authentication', 'phase1_group', 'phase1_sa_lifetime', - 'phase2_encryption', 'phase2_authentication', 'phase2_group', 'phase2_sa_lifetime', 'phase2_sa_lifetime_data', + 'mode', 'ike_policy', 'ipsec_policy', ) class Meta: ordering = ('name',) - verbose_name = _('tunnel') - verbose_name_plural = _('tunnels') + verbose_name = _('IPSec profile') + verbose_name_plural = _('IPSec profiles') def __str__(self): return self.name diff --git a/netbox/vpn/models/tunnels.py b/netbox/vpn/models/tunnels.py index 4f3bd411003..4eba11ad613 100644 --- a/netbox/vpn/models/tunnels.py +++ b/netbox/vpn/models/tunnels.py @@ -45,10 +45,6 @@ class Tunnel(PrimaryModel): blank=True, null=True ) - preshared_key = models.TextField( - verbose_name=_('pre-shared key'), - blank=True - ) tunnel_id = models.PositiveBigIntegerField( verbose_name=_('tunnel ID'), blank=True, diff --git a/netbox/vpn/tables.py b/netbox/vpn/tables.py index 33eb6da85ec..3a358a97295 100644 --- a/netbox/vpn/tables.py +++ b/netbox/vpn/tables.py @@ -1,5 +1,4 @@ import django_tables2 as tables -from django.contrib.contenttypes.fields import GenericRelation from django.utils.translation import gettext_lazy as _ from django_tables2.utils import Accessor @@ -8,6 +7,10 @@ from vpn.models import * __all__ = ( + 'IKEPolicyTable', + 'IKEProposalTable', + 'IPSecPolicyTable', + 'IPSecProposalTable', 'IPSecProfileTable', 'TunnelTable', 'TunnelTerminationTable', @@ -42,8 +45,8 @@ class TunnelTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = Tunnel fields = ( - 'pk', 'id', 'name', 'status', 'encapsulation', 'ipsec_profile', 'tenant', 'tenant_group', 'preshared_key', - 'tunnel_id', 'termination_count', 'description', 'comments', 'tags', 'created', 'last_updated', + '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') @@ -89,47 +92,164 @@ class Meta(NetBoxTable.Meta): default_columns = ('pk', 'tunnel', 'role', 'interface_parent', 'interface', 'ip_addresses', 'outside_ip') -class IPSecProfileTable(TenancyColumnsMixin, NetBoxTable): +class IKEProposalTable(NetBoxTable): name = tables.Column( verbose_name=_('Name'), linkify=True ) - protocol = columns.ChoiceFieldColumn( - verbose_name=_('Protocol') + authentication_method = columns.ChoiceFieldColumn( + verbose_name=_('Authentication Method') ) - ike_version = columns.ChoiceFieldColumn( - verbose_name=_('IKE Version') + encryption_algorithm = columns.ChoiceFieldColumn( + verbose_name=_('Encryption Algorithm') ) - phase1_encryption = columns.ChoiceFieldColumn( - verbose_name=_('Phase 1 Encryption') + authentication_algorithm = columns.ChoiceFieldColumn( + verbose_name=_('Authentication Algorithm') ) - phase1_authentication = columns.ChoiceFieldColumn( - verbose_name=_('Phase 1 Authentication') + group = columns.ChoiceFieldColumn( + verbose_name=_('Group') ) - phase1_group = columns.ChoiceFieldColumn( - verbose_name=_('Phase 1 Group') + sa_lifetime = tables.Column( + verbose_name=_('SA Lifetime') ) - phase2_encryption = columns.ChoiceFieldColumn( - verbose_name=_('Phase 2 Encryption') + 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', '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 ) - phase2_authentication = columns.ChoiceFieldColumn( - verbose_name=_('Phase 2 Authentication') + version = columns.ChoiceFieldColumn( + verbose_name=_('Version') ) - phase2_group = columns.ChoiceFieldColumn( - verbose_name=_('Phase 2 Group') + mode = columns.ChoiceFieldColumn( + verbose_name=_('Mode') + ) + proposals = tables.ManyToManyColumn( + linkify_item=True, + verbose_name=_('Proposals') + ) + preshared_key = tables.Column( + verbose_name=_('Pre-shared Key') + ) + certificate = tables.Column( + verbose_name=_('Certificate') + ) + tags = columns.TagColumn( + url_name='vpn:ikepolicy_list' + ) + + class Meta(NetBoxTable.Meta): + model = IKEPolicy + fields = ( + 'pk', 'id', 'name', 'version', 'mode', 'proposals', 'preshared_key', 'certificate', 'description', '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 = columns.ChoiceFieldColumn( + verbose_name=_('Encryption Algorithm') + ) + authentication_algorithm = columns.ChoiceFieldColumn( + verbose_name=_('Authentication Algorithm') + ) + sa_lifetime_seconds = tables.Column( + verbose_name=_('SA Lifetime (Seconds)') + ) + sa_lifetime_data = tables.Column( + verbose_name=_('SA Lifetime (KB)') + ) + 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', '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 = columns.ChoiceFieldColumn( + verbose_name=_('PFS Group') + ) + tags = columns.TagColumn( + url_name='vpn:ipsecpolicy_list' + ) + + class Meta(NetBoxTable.Meta): + model = IPSecPolicy + fields = ( + 'pk', 'id', 'name', 'proposals', 'pfs_group', 'tags', 'created', 'last_updated', + ) + default_columns = ( + 'pk', 'name', 'proposals', 'pfs_group', 'description', + ) + + +class IPSecProfileTable(NetBoxTable): + name = tables.Column( + verbose_name=_('Name'), + linkify=True + ) + mode = columns.ChoiceFieldColumn( + 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:tunnel_list' + url_name='vpn:ipsecprofile_list' ) class Meta(NetBoxTable.Meta): model = IPSecProfile fields = ( - 'pk', 'id', 'name', 'protocol', 'ike_version', 'phase1_encryption', 'phase1_authentication', 'phase1_group', - 'phase1_sa_lifetime', 'phase2_encryption', 'phase2_authentication', 'phase2_group', 'phase2_sa_lifetime', - 'phase2_sa_lifetime_data', 'description', 'comments', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'mode', 'ike_policy', 'ipsec_policy', 'description', 'comments', 'tags', 'created', + 'last_updated', ) - default_columns = ('pk', 'name', 'protocol', 'ike_version', 'description') + default_columns = ('pk', 'name', 'mode', 'ike_policy', 'ipsec_policy', 'description') diff --git a/netbox/vpn/urls.py b/netbox/vpn/urls.py index bfb348a14e1..7fe54824548 100644 --- a/netbox/vpn/urls.py +++ b/netbox/vpn/urls.py @@ -22,6 +22,38 @@ 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-policys/', views.IKEPolicyListView.as_view(), name='ikepolicy_list'), + path('ike-policys/add/', views.IKEPolicyEditView.as_view(), name='ikepolicy_add'), + path('ike-policys/import/', views.IKEPolicyBulkImportView.as_view(), name='ikepolicy_import'), + path('ike-policys/edit/', views.IKEPolicyBulkEditView.as_view(), name='ikepolicy_bulk_edit'), + path('ike-policys/delete/', views.IKEPolicyBulkDeleteView.as_view(), name='ikepolicy_bulk_delete'), + path('ike-policys//', 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-policys/', views.IPSecPolicyListView.as_view(), name='ipsecpolicy_list'), + path('ipsec-policys/add/', views.IPSecPolicyEditView.as_view(), name='ipsecpolicy_add'), + path('ipsec-policys/import/', views.IPSecPolicyBulkImportView.as_view(), name='ipsecpolicy_import'), + path('ipsec-policys/edit/', views.IPSecPolicyBulkEditView.as_view(), name='ipsecpolicy_bulk_edit'), + path('ipsec-policys/delete/', views.IPSecPolicyBulkDeleteView.as_view(), name='ipsecpolicy_bulk_delete'), + path('ipsec-policys//', 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'), diff --git a/netbox/vpn/views.py b/netbox/vpn/views.py index aa63d00daf3..fe6acb46a5d 100644 --- a/netbox/vpn/views.py +++ b/netbox/vpn/views.py @@ -112,6 +112,186 @@ class TunnelTerminationBulkDeleteView(generic.BulkDeleteView): 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 # From c1ce3c9dc00b5465d2b25f8bac16d6ea244493b1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 21 Nov 2023 09:51:38 -0500 Subject: [PATCH 12/27] Add migrations --- netbox/vpn/migrations/0001_initial.py | 189 ++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 netbox/vpn/migrations/0001_initial.py diff --git a/netbox/vpn/migrations/0001_initial.py b/netbox/vpn/migrations/0001_initial.py new file mode 100644 index 00000000000..46055e9859f --- /dev/null +++ b/netbox/vpn/migrations/0001_initial.py @@ -0,0 +1,189 @@ +# Generated by Django 4.2.7 on 2023-11-21 13:55 + +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 = [ + 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)), + ('name', models.CharField(max_length=100, unique=True)), + ('description', models.CharField(blank=True, max_length=200)), + ('version', models.PositiveSmallIntegerField(default=2)), + ('mode', models.CharField()), + ('preshared_key', models.TextField(blank=True)), + ('certificate', models.TextField(blank=True)), + ], + options={ + 'verbose_name': 'IKE policy', + 'verbose_name_plural': 'IKE policies', + '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)), + ('name', models.CharField(max_length=100, unique=True)), + ('description', models.CharField(blank=True, max_length=200)), + ('pfs_group', models.PositiveSmallIntegerField(blank=True, null=True)), + ], + options={ + 'verbose_name': 'IPSec policy', + 'verbose_name_plural': 'IPSec policies', + 'ordering': ('name',), + }, + ), + 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',), + }, + ), + 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)), + ('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.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)), + ('interface_id', models.PositiveBigIntegerField(blank=True, null=True)), + ('interface_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.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)), + ('name', models.CharField(max_length=100, unique=True)), + ('description', models.CharField(blank=True, max_length=200)), + ('encryption_algorithm', models.CharField()), + ('authentication_algorithm', models.CharField()), + ('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.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='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)), + ('name', models.CharField(max_length=100, unique=True)), + ('description', models.CharField(blank=True, max_length=200)), + ('authentication_method', models.CharField()), + ('encryption_algorithm', models.CharField()), + ('authentication_algorithm', models.CharField()), + ('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.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'), + ), + migrations.AddConstraint( + model_name='tunneltermination', + constraint=models.UniqueConstraint(fields=('interface_type', 'interface_id'), name='vpn_tunneltermination_interface', violation_error_message='An interface may be terminated to only one tunnel at a time.'), + ), + ] From ff1bbf670cf72bf4bf8262104a2b3ca1f9fb4457 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 21 Nov 2023 10:00:49 -0500 Subject: [PATCH 13/27] Cleanup --- netbox/templates/vpn/ipsecprofile.html | 68 ++++--------------------- netbox/vpn/api/nested_serializers.py | 4 +- netbox/vpn/api/serializers.py | 40 ++------------- netbox/vpn/api/views.py | 2 +- netbox/vpn/forms/bulk_edit.py | 70 ++++++++++++++++++++------ netbox/vpn/forms/bulk_import.py | 10 +--- netbox/vpn/forms/filtersets.py | 11 +--- netbox/vpn/forms/model_forms.py | 22 +++++--- netbox/vpn/graphql/schema.py | 25 +++++++++ netbox/vpn/graphql/types.py | 36 +++++++++++++ netbox/vpn/models/crypto.py | 28 ++++++++++- netbox/vpn/tables.py | 20 ++++---- 12 files changed, 189 insertions(+), 147 deletions(-) diff --git a/netbox/templates/vpn/ipsecprofile.html b/netbox/templates/vpn/ipsecprofile.html index d2247bdd07a..aa65393aea8 100644 --- a/netbox/templates/vpn/ipsecprofile.html +++ b/netbox/templates/vpn/ipsecprofile.html @@ -15,76 +15,30 @@
{% trans "IPSec Profile" %}
{{ object.name }} - {% trans "Protocol" %} - {{ object.get_protocol_display }} + {% trans "Description" %} + {{ object.description|placeholder }} - {% trans "IKE Version" %} - {{ object.get_ike_version_display }} + {% trans "Mode" %} + {{ object.get_mode_display }} - {% trans "Description" %} - {{ object.description|placeholder }} + {% trans "IKE Policy" %} + {{ object.ike_policy|linkify }} + + + {% trans "IPSec Policy" %} + {{ object.ipsec_policy|linkify }} - {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
-
-
{% trans "Phase 1 Parameters" %}
-
- - - - - - - - - - - - - - - - - -
{% trans "Encryption" %}{{ object.get_phase1_encryption_display }}
{% trans "Authentication" %}{{ object.get_phase1_authentication_display }}
{% trans "DH Group" %}{{ object.get_phase1_group_display }}
{% trans "SA Lifetime" %}{{ object.phase1_sa_lifetime|placeholder }}
-
-
-
-
{% trans "Phase 2 Parameters" %}
-
- - - - - - - - - - - - - - - - - - - - - -
{% trans "Encryption" %}{{ object.get_phase2_encryption_display }}
{% trans "Authentication" %}{{ object.get_phase2_authentication_display }}
{% trans "DH Group" %}{{ object.get_phase2_group_display }}
{% trans "SA Lifetime (Seconds)" %}{{ object.phase2_sa_lifetime|placeholder }}
{% trans "SA Lifetime (KB)" %}{{ object.phase2_sa_lifetime_data|placeholder }}
-
-
+ {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
diff --git a/netbox/vpn/api/nested_serializers.py b/netbox/vpn/api/nested_serializers.py index 9e2dee8ba56..c9c92d30892 100644 --- a/netbox/vpn/api/nested_serializers.py +++ b/netbox/vpn/api/nested_serializers.py @@ -50,7 +50,7 @@ class NestedIKEPolicySerializer(WritableNestedSerializer): ) class Meta: - model = models.IKEProposal + model = models.IKEPolicy fields = ('id', 'url', 'display', 'name') @@ -70,7 +70,7 @@ class NestedIPSecPolicySerializer(WritableNestedSerializer): ) class Meta: - model = models.IPSecProposal + model = models.IPSecPolicy fields = ('id', 'url', 'display', 'name') diff --git a/netbox/vpn/api/serializers.py b/netbox/vpn/api/serializers.py index 0ebe28dba78..0117aa70f79 100644 --- a/netbox/vpn/api/serializers.py +++ b/netbox/vpn/api/serializers.py @@ -118,12 +118,6 @@ class IKEPolicySerializer(NetBoxModelSerializer): mode = ChoiceField( choices=IKEModeChoices ) - authentication_algorithm = ChoiceField( - choices=AuthenticationAlgorithmChoices - ) - group = ChoiceField( - choices=DHGroupChoices - ) proposals = SerializedPKRelatedField( queryset=IKEProposal.objects.all(), serializer=NestedIKEProposalSerializer, @@ -149,9 +143,6 @@ class IPSecProposalSerializer(NetBoxModelSerializer): authentication_algorithm = ChoiceField( choices=AuthenticationAlgorithmChoices ) - group = ChoiceField( - choices=DHGroupChoices - ) class Meta: model = IPSecProposal @@ -187,36 +178,15 @@ class IPSecProfileSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField( view_name='vpn-api:ipsecprofile-detail' ) - protocol = ChoiceField( + mode = ChoiceField( choices=IPSecModeChoices ) - ike_version = ChoiceField( - choices=IKEVersionChoices - ) - phase1_encryption = ChoiceField( - choices=EncryptionAlgorithmChoices - ) - phase1_authentication = ChoiceField( - choices=AuthenticationAlgorithmChoices - ) - phase1_group = ChoiceField( - choices=DHGroupChoices - ) - phase2_encryption = ChoiceField( - choices=EncryptionAlgorithmChoices - ) - phase2_authentication = ChoiceField( - choices=AuthenticationAlgorithmChoices - ) - phase2_group = ChoiceField( - choices=DHGroupChoices - ) + ike_policy = NestedIKEPolicySerializer() + ipsec_policy = NestedIPSecPolicySerializer() class Meta: model = IPSecProfile fields = ( - 'id', 'url', 'display', 'name', 'protocol', 'ike_version', 'phase1_encryption', 'phase1_authentication', - 'phase1_group', 'phase1_sa_lifetime', 'phase2_encryption', 'phase2_authentication', 'phase2_group', - 'phase2_sa_lifetime', 'phase2_sa_lifetime_data', 'comments', 'tags', 'custom_fields', 'created', - 'last_updated', + 'id', 'url', 'display', 'name', 'description', 'mode', 'ike_policy', 'ipsec_policy', 'comments', 'tags', + 'custom_fields', 'created', 'last_updated', ) diff --git a/netbox/vpn/api/views.py b/netbox/vpn/api/views.py index 960bcb1b4e1..c0ccab7ab74 100644 --- a/netbox/vpn/api/views.py +++ b/netbox/vpn/api/views.py @@ -63,7 +63,7 @@ class IPSecProposalViewSet(NetBoxModelViewSet): class IPSecPolicyViewSet(NetBoxModelViewSet): - queryset = IKEPolicy.objects.all() + queryset = IPSecPolicy.objects.all() serializer_class = serializers.IPSecPolicySerializer filterset_class = filtersets.IPSecPolicyFilterSet diff --git a/netbox/vpn/forms/bulk_edit.py b/netbox/vpn/forms/bulk_edit.py index d645ca02b44..9a6b87c313e 100644 --- a/netbox/vpn/forms/bulk_edit.py +++ b/netbox/vpn/forms/bulk_edit.py @@ -97,18 +97,25 @@ class IKEProposalBulkEditForm(NetBoxModelBulkEditForm): 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, ('name', 'description')), - (_('Parameters'), ( + (None, ( 'authentication_method', 'encryption_algorithm', 'authentication_algorithm', 'group', 'sa_lifetime', + 'description', )), ) nullable_fields = ( - 'description', 'sa_lifetime', 'comments', + 'sa_lifetime', 'description', 'comments', ) @@ -131,16 +138,21 @@ class IKEPolicyBulkEditForm(NetBoxModelBulkEditForm): label=_('Certificate'), required=False ) + description = forms.CharField( + label=_('Description'), + max_length=200, + required=False + ) + comments = CommentField() model = IKEPolicy fieldsets = ( - (None, ('name', 'description')), - (_('Parameters'), ( - 'version', 'mode', 'preshared_key', 'certificate', + (None, ( + 'version', 'mode', 'preshared_key', 'certificate', 'description', )), ) nullable_fields = ( - 'description', 'preshared_key', 'certificate', 'comments', + 'preshared_key', 'certificate', 'description', 'comments', ) @@ -156,21 +168,29 @@ class IPSecProposalBulkEditForm(NetBoxModelBulkEditForm): 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, ('name', 'description')), - (_('Parameters'), ( + (None, ( 'encryption_algorithm', 'authentication_algorithm', 'sa_lifetime_seconds', 'sa_lifetime_data', + 'description', )), ) nullable_fields = ( - 'description', 'sa_lifetime_seconds', 'sa_lifetime_data', 'comments', + 'sa_lifetime_seconds', 'sa_lifetime_data', 'description', 'comments', ) @@ -180,20 +200,38 @@ class IPSecPolicyBulkEditForm(NetBoxModelBulkEditForm): choices=add_blank_choice(DHGroupChoices), required=False ) + description = forms.CharField( + label=_('Description'), + max_length=200, + required=False + ) + comments = CommentField() model = IPSecPolicy fieldsets = ( - (None, ('name', 'description')), - (_('Parameters'), ( - 'pfs_group', - )), + (None, ('pfs_group', 'description',)), ) nullable_fields = ( - 'description', 'pfs_group', 'comments', + '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, @@ -204,7 +242,7 @@ class IPSecProfileBulkEditForm(NetBoxModelBulkEditForm): model = IPSecProfile fieldsets = ( (_('Profile'), ( - 'protocol', 'ike_version', 'description', + 'mode', 'ike_policy', 'ipsec_policy', 'description', )), ) nullable_fields = ( diff --git a/netbox/vpn/forms/bulk_import.py b/netbox/vpn/forms/bulk_import.py index 1b19af25f37..d961b156c67 100644 --- a/netbox/vpn/forms/bulk_import.py +++ b/netbox/vpn/forms/bulk_import.py @@ -157,10 +157,6 @@ class Meta: class IPSecProposalImportForm(NetBoxModelImportForm): - authentication_method = CSVChoiceField( - label=_('Authentication method'), - choices=AuthenticationMethodChoices - ) encryption_algorithm = CSVChoiceField( label=_('Encryption algorithm'), choices=EncryptionAlgorithmChoices @@ -169,10 +165,6 @@ class IPSecProposalImportForm(NetBoxModelImportForm): label=_('Authentication algorithm'), choices=AuthenticationAlgorithmChoices ) - group = CSVChoiceField( - label=_('Group'), - choices=DHGroupChoices - ) class Meta: model = IPSecProposal @@ -216,5 +208,5 @@ class IPSecProfileImportForm(NetBoxModelImportForm): class Meta: model = IPSecProfile fields = ( - 'name', 'ike_policy', 'ipsec_policy', 'description', 'comments', 'tags', + 'name', 'mode', 'ike_policy', 'ipsec_policy', 'description', 'comments', 'tags', ) diff --git a/netbox/vpn/forms/filtersets.py b/netbox/vpn/forms/filtersets.py index 98a62685758..ec146919a70 100644 --- a/netbox/vpn/forms/filtersets.py +++ b/netbox/vpn/forms/filtersets.py @@ -143,7 +143,7 @@ class IPSecPolicyFilterForm(NetBoxModelFilterSetForm): model = IPSecPolicy fieldsets = ( (None, ('q', 'filter_id', 'tag')), - (_('Parameters'), ('proposal', 'pfs_group')), + (_('Parameters'), ('proposal_id', 'pfs_group')), ) proposal_id = DynamicModelMultipleChoiceField( queryset=IKEProposal.objects.all(), @@ -162,14 +162,7 @@ class IPSecProfileFilterForm(NetBoxModelFilterSetForm): model = IPSecProfile fieldsets = ( (None, ('q', 'filter_id', 'tag')), - (_('Profile'), ('protocol', 'ike_version')), - (_('Phase 1 Parameters'), ( - 'phase1_encryption', 'phase1_authentication', 'phase1_group', 'phase1_sa_lifetime', - )), - (_('Phase 2 Parameters'), ( - 'phase2_encryption', 'phase2_authentication', 'phase2_group', 'phase2_sa_lifetime', - 'phase2_sa_lifetime_data', - )), + (_('Profile'), ('mode', 'ike_policy_id', 'ipsec_policy_id')), ) mode = forms.MultipleChoiceField( label=_('Mode'), diff --git a/netbox/vpn/forms/model_forms.py b/netbox/vpn/forms/model_forms.py index 6f289d26e69..49b9dab2435 100644 --- a/netbox/vpn/forms/model_forms.py +++ b/netbox/vpn/forms/model_forms.py @@ -287,7 +287,8 @@ class Meta: class IKEPolicyForm(NetBoxModelForm): proposals = DynamicModelMultipleChoiceField( - queryset=IKEProposal.objects.all() + queryset=IKEProposal.objects.all(), + label=_('Proposals') ) fieldsets = ( @@ -322,7 +323,8 @@ class Meta: class IPSecPolicyForm(NetBoxModelForm): proposals = DynamicModelMultipleChoiceField( - queryset=IPSecProposal.objects.all() + queryset=IPSecProposal.objects.all(), + label=_('Proposals') ) fieldsets = ( @@ -338,17 +340,23 @@ class Meta: 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', 'mode', 'description', 'tags', - )), - (_('Policies'), ('ipsec_policy', 'description', 'tags')), + (_('Profile'), ('name', 'description', 'tags')), + (_('Parameters'), ('mode', 'ike_policy', 'ipsec_policy')), ) class Meta: model = IPSecProfile fields = [ - 'name', 'description', 'mode', 'ipsec_policy', 'description', 'comments', 'tags', + 'name', 'description', 'mode', 'ike_policy', 'ipsec_policy', 'description', 'comments', 'tags', ] diff --git a/netbox/vpn/graphql/schema.py b/netbox/vpn/graphql/schema.py index 0ec4cd20746..64e6808823d 100644 --- a/netbox/vpn/graphql/schema.py +++ b/netbox/vpn/graphql/schema.py @@ -7,12 +7,37 @@ 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) + tunnel = ObjectField(TunnelType) tunnel_list = ObjectListField(TunnelType) diff --git a/netbox/vpn/graphql/types.py b/netbox/vpn/graphql/types.py index d6b04ad2f5d..f46e8b69702 100644 --- a/netbox/vpn/graphql/types.py +++ b/netbox/vpn/graphql/types.py @@ -3,7 +3,11 @@ from vpn import filtersets, models __all__ = ( + 'IKEPolicyType', + 'IKEProposalType', + 'IPSecPolicyType', 'IPSecProfileType', + 'IPSecProposalType', 'TunnelTerminationType', 'TunnelType', ) @@ -25,6 +29,38 @@ class Meta: 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: diff --git a/netbox/vpn/models/crypto.py b/netbox/vpn/models/crypto.py index dacd40f81ac..84a010a3e26 100644 --- a/netbox/vpn/models/crypto.py +++ b/netbox/vpn/models/crypto.py @@ -53,6 +53,10 @@ class IKEProposal(NetBoxModel): 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') @@ -99,6 +103,13 @@ class IKEPolicy(NetBoxModel): blank=True ) + clone_fields = ( + 'version', 'mode', 'proposals', + ) + prerequisite_models = ( + 'vpn.IKEProposal', + ) + class Meta: ordering = ('name',) verbose_name = _('IKE policy') @@ -108,7 +119,7 @@ def __str__(self): return self.name def get_absolute_url(self): - return reverse('vpn:ikeprofile', args=[self.pk]) + return reverse('vpn:ikepolicy', args=[self.pk]) # @@ -147,6 +158,10 @@ class IPSecProposal(NetBoxModel): 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') @@ -183,6 +198,13 @@ class IPSecPolicy(NetBoxModel): 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') @@ -219,6 +241,10 @@ class IPSecProfile(PrimaryModel): clone_fields = ( 'mode', 'ike_policy', 'ipsec_policy', ) + prerequisite_models = ( + 'vpn.IKEPolicy', + 'vpn.IPSecPolicy', + ) class Meta: ordering = ('name',) diff --git a/netbox/vpn/tables.py b/netbox/vpn/tables.py index 3a358a97295..ab16429695e 100644 --- a/netbox/vpn/tables.py +++ b/netbox/vpn/tables.py @@ -97,16 +97,16 @@ class IKEProposalTable(NetBoxTable): verbose_name=_('Name'), linkify=True ) - authentication_method = columns.ChoiceFieldColumn( + authentication_method = tables.Column( verbose_name=_('Authentication Method') ) - encryption_algorithm = columns.ChoiceFieldColumn( + encryption_algorithm = tables.Column( verbose_name=_('Encryption Algorithm') ) - authentication_algorithm = columns.ChoiceFieldColumn( + authentication_algorithm = tables.Column( verbose_name=_('Authentication Algorithm') ) - group = columns.ChoiceFieldColumn( + group = tables.Column( verbose_name=_('Group') ) sa_lifetime = tables.Column( @@ -133,10 +133,10 @@ class IKEPolicyTable(NetBoxTable): verbose_name=_('Name'), linkify=True ) - version = columns.ChoiceFieldColumn( + version = tables.Column( verbose_name=_('Version') ) - mode = columns.ChoiceFieldColumn( + mode = tables.Column( verbose_name=_('Mode') ) proposals = tables.ManyToManyColumn( @@ -169,10 +169,10 @@ class IPSecProposalTable(NetBoxTable): verbose_name=_('Name'), linkify=True ) - encryption_algorithm = columns.ChoiceFieldColumn( + encryption_algorithm = tables.Column( verbose_name=_('Encryption Algorithm') ) - authentication_algorithm = columns.ChoiceFieldColumn( + authentication_algorithm = tables.Column( verbose_name=_('Authentication Algorithm') ) sa_lifetime_seconds = tables.Column( @@ -206,7 +206,7 @@ class IPSecPolicyTable(NetBoxTable): linkify_item=True, verbose_name=_('Proposals') ) - pfs_group = columns.ChoiceFieldColumn( + pfs_group = tables.Column( verbose_name=_('PFS Group') ) tags = columns.TagColumn( @@ -228,7 +228,7 @@ class IPSecProfileTable(NetBoxTable): verbose_name=_('Name'), linkify=True ) - mode = columns.ChoiceFieldColumn( + mode = tables.Column( verbose_name=_('Mode') ) ike_policy = tables.Column( From f600f908cf33f4e02fb290d54f0a8c5253e0b1a5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 21 Nov 2023 14:05:42 -0500 Subject: [PATCH 14/27] Add REST API tests --- netbox/vpn/api/serializers.py | 5 +- netbox/vpn/models/tunnels.py | 2 +- netbox/vpn/tests/__init__.py | 0 netbox/vpn/tests/test_api.py | 473 ++++++++++++++++++++++++++++++++++ 4 files changed, 477 insertions(+), 3 deletions(-) create mode 100644 netbox/vpn/tests/__init__.py create mode 100644 netbox/vpn/tests/test_api.py diff --git a/netbox/vpn/api/serializers.py b/netbox/vpn/api/serializers.py index 0117aa70f79..30cfc5dce85 100644 --- a/netbox/vpn/api/serializers.py +++ b/netbox/vpn/api/serializers.py @@ -46,7 +46,7 @@ class Meta: model = Tunnel fields = ( 'id', 'url', 'display', 'name', 'status', 'encapsulation', 'ipsec_profile', 'tenant', 'tunnel_id', - 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ) @@ -163,7 +163,8 @@ class IPSecPolicySerializer(NetBoxModelSerializer): many=True ) pfs_group = ChoiceField( - choices=DHGroupChoices + choices=DHGroupChoices, + required=False ) class Meta: diff --git a/netbox/vpn/models/tunnels.py b/netbox/vpn/models/tunnels.py index 4eba11ad613..9f696dbbd03 100644 --- a/netbox/vpn/models/tunnels.py +++ b/netbox/vpn/models/tunnels.py @@ -128,7 +128,7 @@ def clean(self): super().clean() # Check that the selected Interface is not already attached to a Tunnel - if self.interface.tunnel_termination: + if self.interface.tunnel_termination and self.interface.tunnel_termination.pk != self.pk: raise ValidationError({ 'interface': _("Interface {name} is already attached to a tunnel ({tunnel}).").format( name=self.interface.name, diff --git a/netbox/vpn/tests/__init__.py b/netbox/vpn/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/netbox/vpn/tests/test_api.py b/netbox/vpn/tests/test_api.py new file mode 100644 index 00000000000..cdb127ef1c3 --- /dev/null +++ b/netbox/vpn/tests/test_api.py @@ -0,0 +1,473 @@ +from django.urls import reverse + +from dcim.choices import InterfaceTypeChoices +from dcim.models import Interface +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 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): + + 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) + + cls.create_data = [ + { + 'name': 'Tunnel 4', + 'status': TunnelStatusChoices.STATUS_DISABLED, + 'encapsulation': TunnelEncapsulationChoices.ENCAP_GRE, + }, + { + 'name': 'Tunnel 5', + 'status': TunnelStatusChoices.STATUS_DISABLED, + 'encapsulation': TunnelEncapsulationChoices.ENCAP_GRE, + }, + { + 'name': 'Tunnel 6', + 'status': TunnelStatusChoices.STATUS_DISABLED, + '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, + interface=interfaces[0] + ), + TunnelTermination( + tunnel=tunnel, + role=TunnelTerminationRoleChoices.ROLE_HUB, + interface=interfaces[1] + ), + TunnelTermination( + tunnel=tunnel, + role=TunnelTerminationRoleChoices.ROLE_HUB, + interface=interfaces[2] + ), + ) + TunnelTermination.objects.bulk_create(tunnel_terminations) + + cls.create_data = [ + { + 'tunnel': tunnel.pk, + 'role': TunnelTerminationRoleChoices.ROLE_PEER, + 'interface_type': 'dcim.interface', + 'interface_id': interfaces[3].pk, + }, + { + 'tunnel': tunnel.pk, + 'role': TunnelTerminationRoleChoices.ROLE_PEER, + 'interface_type': 'dcim.interface', + 'interface_id': interfaces[4].pk, + }, + { + 'tunnel': tunnel.pk, + 'role': TunnelTerminationRoleChoices.ROLE_PEER, + 'interface_type': 'dcim.interface', + 'interface_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', + } From c814e763a1e76d72598b556c5ab5ca5cdfc948e1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 21 Nov 2023 16:04:33 -0500 Subject: [PATCH 15/27] Add UI view tests --- netbox/templates/vpn/tunneltermination.html | 55 +++ netbox/vpn/forms/bulk_import.py | 32 +- netbox/vpn/forms/model_forms.py | 68 ++- netbox/vpn/models/tunnels.py | 4 +- netbox/vpn/tables.py | 2 +- netbox/vpn/tests/test_views.py | 509 ++++++++++++++++++++ netbox/vpn/views.py | 13 +- 7 files changed, 629 insertions(+), 54 deletions(-) create mode 100644 netbox/templates/vpn/tunneltermination.html create mode 100644 netbox/vpn/tests/test_views.py diff --git a/netbox/templates/vpn/tunneltermination.html b/netbox/templates/vpn/tunneltermination.html new file mode 100644 index 00000000000..178b97ef56b --- /dev/null +++ b/netbox/templates/vpn/tunneltermination.html @@ -0,0 +1,55 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load i18n %} + +{% block content %} +
+
+
+
{% trans "Tunnel Termination" %}
+
+ + + + + + + + + + + + + + + + + + + + + +
{% trans "Tunnel" %}{{ object.tunnel|linkify }}
{% trans "Role" %}{{ object.get_role_display }}
+ {% if object.interface.device %} + {% trans "Device" %} + {% elif object.interface.virtual_machine %} + {% trans "Virtual Machine" %} + {% endif %} + {{ object.interface.parent_object|linkify }}
{% trans "Interface" %}{{ object.interface|linkify }}
{% trans "Outside IP" %}{{ object.outside_ip|linkify|placeholder }}
+
+
+ {% plugin_left_page object %} +
+
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% plugin_right_page object %} +
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/vpn/forms/bulk_import.py b/netbox/vpn/forms/bulk_import.py index d961b156c67..a815aa10b9c 100644 --- a/netbox/vpn/forms/bulk_import.py +++ b/netbox/vpn/forms/bulk_import.py @@ -4,7 +4,7 @@ from ipam.models import IPAddress from netbox.forms import NetBoxModelImportForm from tenancy.models import Tenant -from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField +from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField from virtualization.models import VirtualMachine, VMInterface from vpn.choices import * from vpn.models import * @@ -34,6 +34,7 @@ class TunnelImportForm(NetBoxModelImportForm): ipsec_profile = CSVModelChoiceField( label=_('IPSec profile'), queryset=IPSecProfile.objects.all(), + required=False, to_field_name='name' ) tenant = CSVModelChoiceField( @@ -87,6 +88,7 @@ class TunnelTerminationImportForm(NetBoxModelImportForm): outside_ip = CSVModelChoiceField( label=_('Outside IP'), queryset=IPAddress.objects.all(), + required=False, to_field_name='name' ) @@ -111,6 +113,14 @@ def __init__(self, data=None, *args, **kwargs): **{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']} ) + def save(self, *args, **kwargs): + + # Set interface assignment + if self.cleaned_data.get('interface'): + self.instance.interface = self.cleaned_data['interface'] + + return super().save(*args, **kwargs) + class IKEProposalImportForm(NetBoxModelImportForm): authentication_method = CSVChoiceField( @@ -121,7 +131,7 @@ class IKEProposalImportForm(NetBoxModelImportForm): label=_('Encryption algorithm'), choices=EncryptionAlgorithmChoices ) - authentication_algorithmn = CSVChoiceField( + authentication_algorithm = CSVChoiceField( label=_('Authentication algorithm'), choices=AuthenticationAlgorithmChoices ) @@ -133,7 +143,7 @@ class IKEProposalImportForm(NetBoxModelImportForm): class Meta: model = IKEProposal fields = ( - 'name', 'description', 'authentication_method', 'encryption_algorithm', 'authentication_algorithmn', + 'name', 'description', 'authentication_method', 'encryption_algorithm', 'authentication_algorithm', 'group', 'sa_lifetime', 'tags', ) @@ -147,7 +157,11 @@ class IKEPolicyImportForm(NetBoxModelImportForm): label=_('Mode'), choices=IKEModeChoices ) - # TODO: M2M field for proposals + proposals = CSVModelMultipleChoiceField( + queryset=IKEProposal.objects.all(), + to_field_name='name', + help_text=_('IKE proposal(s)'), + ) class Meta: model = IKEPolicy @@ -161,7 +175,7 @@ class IPSecProposalImportForm(NetBoxModelImportForm): label=_('Encryption algorithm'), choices=EncryptionAlgorithmChoices ) - authentication_algorithmn = CSVChoiceField( + authentication_algorithm = CSVChoiceField( label=_('Authentication algorithm'), choices=AuthenticationAlgorithmChoices ) @@ -169,7 +183,7 @@ class IPSecProposalImportForm(NetBoxModelImportForm): class Meta: model = IPSecProposal fields = ( - 'name', 'description', 'encryption_algorithm', 'authentication_algorithmn', 'sa_lifetime_seconds', + 'name', 'description', 'encryption_algorithm', 'authentication_algorithm', 'sa_lifetime_seconds', 'sa_lifetime_data', 'tags', ) @@ -179,7 +193,11 @@ class IPSecPolicyImportForm(NetBoxModelImportForm): label=_('PFS group'), choices=DHGroupChoices ) - # TODO: M2M field for proposals + proposals = CSVModelMultipleChoiceField( + queryset=IPSecProposal.objects.all(), + to_field_name='name', + help_text=_('IPSec proposal(s)'), + ) class Meta: model = IPSecPolicy diff --git a/netbox/vpn/forms/model_forms.py b/netbox/vpn/forms/model_forms.py index 49b9dab2435..79d02917268 100644 --- a/netbox/vpn/forms/model_forms.py +++ b/netbox/vpn/forms/model_forms.py @@ -6,6 +6,7 @@ from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField +from utilities.forms.utils import add_blank_choice from utilities.forms.widgets import HTMXSelect from virtualization.models import VirtualMachine, VMInterface from vpn.choices import * @@ -20,7 +21,6 @@ 'TunnelCreateForm', 'TunnelForm', 'TunnelTerminationForm', - 'TunnelTerminationCreateForm', ) @@ -49,21 +49,25 @@ class Meta: class TunnelCreateForm(TunnelForm): # First termination termination1_role = forms.ChoiceField( - choices=TunnelTerminationRoleChoices, + 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_interface = DynamicModelChoiceField( queryset=Interface.objects.all(), + required=False, label=_('Interface'), query_params={ 'device_id': '$termination1_parent', @@ -80,7 +84,7 @@ class TunnelCreateForm(TunnelForm): # Second termination termination2_role = forms.ChoiceField( - choices=TunnelTerminationRoleChoices, + choices=add_blank_choice(TunnelTerminationRoleChoices), required=False, label=_('Role') ) @@ -155,34 +159,36 @@ def __init__(self, *args, initial=None, **kwargs): def clean(self): super().clean() - # Check that all required parameters have been set for the second termination (if any) - termination2_required_parameters = ( - 'termination2_role', 'termination2_type', 'termination2_parent', 'termination2_interface', - ) - termination2_parameters = ( - *termination2_required_parameters, - 'termination2_outside_ip', - ) - if any([self.cleaned_data[param] for param in termination2_parameters]): - for param in termination2_required_parameters: + # Validate attributes for each termination (if any) + for term in ('termination1', 'termination2'): + required_parameters = ( + f'{term}_role', f'{term}_parent', f'{term}_interface', + ) + 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 second termination.") + param: _("This parameter is required when defining a termination.") }) def save(self, *args, **kwargs): instance = super().save(*args, **kwargs) # Create first termination - TunnelTermination.objects.create( - tunnel=instance, - role=self.cleaned_data['termination1_role'], - interface=self.cleaned_data['termination1_interface'], - outside_ip=self.cleaned_data['termination1_outside_ip'], - ) + if self.cleaned_data['termination1_interface']: + TunnelTermination.objects.create( + tunnel=instance, + role=self.cleaned_data['termination1_role'], + interface=self.cleaned_data['termination1_interface'], + outside_ip=self.cleaned_data['termination1_outside_ip'], + ) # Create second termination, if defined - if self.cleaned_data['termination2_role']: + if self.cleaned_data['termination2_interface']: TunnelTermination.objects.create( tunnel=instance, role=self.cleaned_data['termination2_role'], @@ -194,20 +200,6 @@ def save(self, *args, **kwargs): class TunnelTerminationForm(NetBoxModelForm): - outside_ip = DynamicModelChoiceField( - queryset=IPAddress.objects.all(), - required=False, - label=_('Outside IP') - ) - - class Meta: - model = TunnelTermination - fields = [ - 'role', 'outside_ip', 'tags', - ] - - -class TunnelTerminationCreateForm(NetBoxModelForm): tunnel = DynamicModelChoiceField( queryset=Tunnel.objects.all() ) @@ -261,11 +253,15 @@ def __init__(self, *args, initial=None, **kwargs): 'virtual_machine_id': '$parent', }) + if self.instance.pk: + self.fields['parent'].initial = self.instance.interface.parent_object + self.fields['interface'].initial = self.instance.interface + def clean(self): super().clean() # Assign the interface - self.instance.interface = self.cleaned_data['interface'] + self.instance.interface = self.cleaned_data.get('interface') class IKEProposalForm(NetBoxModelForm): diff --git a/netbox/vpn/models/tunnels.py b/netbox/vpn/models/tunnels.py index 9f696dbbd03..6603de87cf4 100644 --- a/netbox/vpn/models/tunnels.py +++ b/netbox/vpn/models/tunnels.py @@ -119,7 +119,7 @@ def __str__(self): return f'{self.tunnel}: Termination {self.pk}' def get_absolute_url(self): - return self.tunnel.get_absolute_url() + return reverse('vpn:tunneltermination', args=[self.pk]) def get_role_color(self): return TunnelTerminationRoleChoices.colors.get(self.role) @@ -128,7 +128,7 @@ def clean(self): super().clean() # Check that the selected Interface is not already attached to a Tunnel - if self.interface.tunnel_termination and self.interface.tunnel_termination.pk != self.pk: + if getattr(self.interface, 'tunnel_termination', None) and self.interface.tunnel_termination.pk != self.pk: raise ValidationError({ 'interface': _("Interface {name} is already attached to a tunnel ({tunnel}).").format( name=self.interface.name, diff --git a/netbox/vpn/tables.py b/netbox/vpn/tables.py index ab16429695e..b375674c9cc 100644 --- a/netbox/vpn/tables.py +++ b/netbox/vpn/tables.py @@ -89,7 +89,7 @@ class Meta(NetBoxTable.Meta): 'pk', 'id', 'tunnel', 'role', 'interface_parent', 'interface', 'ip_addresses', 'outside_ip', 'tags', 'created', 'last_updated', ) - default_columns = ('pk', 'tunnel', 'role', 'interface_parent', 'interface', 'ip_addresses', 'outside_ip') + default_columns = ('pk', 'id', 'tunnel', 'role', 'interface_parent', 'interface', 'ip_addresses', 'outside_ip') class IKEProposalTable(NetBoxTable): diff --git a/netbox/vpn/tests/test_views.py b/netbox/vpn/tests/test_views.py new file mode 100644 index 00000000000..88803d921e3 --- /dev/null +++ b/netbox/vpn/tests/test_views.py @@ -0,0 +1,509 @@ +from django.contrib.contenttypes.models import ContentType + +from dcim.choices import InterfaceTypeChoices +from dcim.models import Interface +from vpn.choices import * +from vpn.models import * +from utilities.testing import ViewTestCases, create_tags, create_test_device + + +class TunnelTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = Tunnel + + @classmethod + def setUpTestData(cls): + + 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) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'name': 'Tunnel X', + 'description': 'New tunnel', + 'status': TunnelStatusChoices.STATUS_PLANNED, + 'encapsulation': TunnelEncapsulationChoices.ENCAP_GRE, + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "name,status,encapsulation", + "Tunnel 4,planned,gre", + "Tunnel 5,planned,gre", + "Tunnel 6,planned,gre", + ) + + cls.csv_update_data = ( + "id,status,encapsulation", + f"{tunnels[0].pk},active,ip-ip", + f"{tunnels[1].pk},active,ip-ip", + f"{tunnels[2].pk},active,ip-ip", + ) + + cls.bulk_edit_data = { + 'description': 'New description', + 'status': TunnelStatusChoices.STATUS_DISABLED, + 'encapsulation': TunnelEncapsulationChoices.ENCAP_GRE, + } + + +class TunnelTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = TunnelTermination + + @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, + interface=interfaces[0] + ), + TunnelTermination( + tunnel=tunnel, + role=TunnelTerminationRoleChoices.ROLE_SPOKE, + interface=interfaces[1] + ), + TunnelTermination( + tunnel=tunnel, + role=TunnelTerminationRoleChoices.ROLE_SPOKE, + interface=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, + # TODO: Solve for GFK validation + 'interface': interfaces[6].pk, + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "tunnel,role,device,interface", + "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, + } diff --git a/netbox/vpn/views.py b/netbox/vpn/views.py index fe6acb46a5d..56eadc07715 100644 --- a/netbox/vpn/views.py +++ b/netbox/vpn/views.py @@ -75,19 +75,16 @@ class TunnelTerminationListView(generic.ObjectListView): 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 - def dispatch(self, request, *args, **kwargs): - - # If creating a new Tunnel, use the creation form - if 'pk' not in kwargs: - self.form = forms.TunnelTerminationCreateForm - - return super().dispatch(request, *args, **kwargs) - @register_model_view(TunnelTermination, 'delete') class TunnelTerminationDeleteView(generic.ObjectDeleteView): From e965c6c3eee2e7954e3b76610a71b12c96fce0d8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 21 Nov 2023 16:54:28 -0500 Subject: [PATCH 16/27] Add filterset tests --- netbox/vpn/filtersets.py | 12 +- netbox/vpn/tests/test_filtersets.py | 539 ++++++++++++++++++++++++++++ 2 files changed, 547 insertions(+), 4 deletions(-) create mode 100644 netbox/vpn/tests/test_filtersets.py diff --git a/netbox/vpn/filtersets.py b/netbox/vpn/filtersets.py index a417f4e320b..4a2d5956aa6 100644 --- a/netbox/vpn/filtersets.py +++ b/netbox/vpn/filtersets.py @@ -6,7 +6,7 @@ from ipam.models import IPAddress from netbox.filtersets import NetBoxModelFilterSet from tenancy.filtersets import TenancyFilterSet -from utilities.filters import ContentTypeFilter, MultiValueNumberFilter +from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter from virtualization.models import VMInterface from .choices import * from .models import * @@ -62,7 +62,7 @@ class TunnelTerminationFilterSet(NetBoxModelFilterSet): ) tunnel = django_filters.ModelMultipleChoiceFilter( field_name='tunnel__name', - queryset=IPSecProfile.objects.all(), + queryset=Tunnel.objects.all(), to_field_name='name', label=_('Tunnel (name)'), ) @@ -139,7 +139,9 @@ class IKEPolicyFilterSet(NetBoxModelFilterSet): proposal_id = MultiValueNumberFilter( field_name='proposals__id' ) - proposals = ContentTypeFilter() + proposal = MultiValueCharFilter( + field_name='proposals__name' + ) class Meta: model = IKEPolicy @@ -182,7 +184,9 @@ class IPSecPolicyFilterSet(NetBoxModelFilterSet): proposal_id = MultiValueNumberFilter( field_name='proposals__id' ) - proposals = ContentTypeFilter() + proposal = MultiValueCharFilter( + field_name='proposals__name' + ) class Meta: model = IPSecPolicy diff --git a/netbox/vpn/tests/test_filtersets.py b/netbox/vpn/tests/test_filtersets.py new file mode 100644 index 00000000000..9e53d8cd27c --- /dev/null +++ b/netbox/vpn/tests/test_filtersets.py @@ -0,0 +1,539 @@ +from django.test import TestCase + +from dcim.choices import InterfaceTypeChoices +from dcim.models import Interface +from ipam.models import IPAddress +from vpn.choices import * +from vpn.filtersets import * +from vpn.models import * +from utilities.testing import ChangeLoggedFilterSetTests, create_test_device + + +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) + + tunnels = ( + Tunnel( + name='Tunnel 1', + status=TunnelStatusChoices.STATUS_ACTIVE, + encapsulation=TunnelEncapsulationChoices.ENCAP_GRE, + ipsec_profile=ipsec_profiles[0], + tunnel_id=100 + ), + Tunnel( + name='Tunnel 2', + status=TunnelStatusChoices.STATUS_PLANNED, + encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP, + ipsec_profile=ipsec_profiles[0], + tunnel_id=200 + ), + Tunnel( + name='Tunnel 3', + status=TunnelStatusChoices.STATUS_DISABLED, + encapsulation=TunnelEncapsulationChoices.ENCAP_IPSEC_TUNNEL, + ipsec_profile=None, + tunnel_id=300 + ), + ) + Tunnel.objects.bulk_create(tunnels) + + 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_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) + + +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) + + ip_addresses = ( + IPAddress(address='192.168.0.1/32'), + IPAddress(address='192.168.0.2/32'), + IPAddress(address='192.168.0.3/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 = ( + TunnelTermination( + tunnel=tunnels[0], + role=TunnelTerminationRoleChoices.ROLE_HUB, + interface=interfaces[0], + outside_ip=ip_addresses[0] + ), + TunnelTermination( + tunnel=tunnels[1], + role=TunnelTerminationRoleChoices.ROLE_SPOKE, + interface=interfaces[1], + outside_ip=ip_addresses[1] + ), + TunnelTermination( + tunnel=tunnels[2], + role=TunnelTerminationRoleChoices.ROLE_PEER, + interface=interfaces[2], + outside_ip=ip_addresses[2] + ), + ) + 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(), 2) + params = {'tunnel': [tunnels[0].name, tunnels[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_role(self): + params = {'role': [TunnelTerminationRoleChoices.ROLE_HUB, TunnelTerminationRoleChoices.ROLE_SPOKE]} + 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 + ), + 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 + ), + 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 + ), + ) + IKEProposal.objects.bulk_create(ike_proposals) + + def test_name(self): + params = {'name': ['IKE Proposal 1', 'IKE Proposal 2']} + 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, + ), + IKEPolicy( + name='IKE Policy 2', + version=IKEVersionChoices.VERSION_1, + mode=IKEModeChoices.MAIN, + ), + IKEPolicy( + name='IKE Policy 3', + version=IKEVersionChoices.VERSION_2, + mode=IKEModeChoices.AGGRESSIVE, + ), + ) + 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_name(self): + params = {'name': ['IKE Policy 1', 'IKE Policy 2']} + 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 + ), + 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 + ), + 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 + ), + ) + IPSecProposal.objects.bulk_create(ipsec_proposals) + + def test_name(self): + params = {'name': ['IPSec Proposal 1', 'IPSec Proposal 2']} + 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 + ), + IPSecPolicy( + name='IPSec Policy 2', + pfs_group=DHGroupChoices.GROUP_2 + ), + IPSecPolicy( + name='IPSec Policy 3', + pfs_group=DHGroupChoices.GROUP_5 + ), + ) + 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_name(self): + params = {'name': ['IPSec Policy 1', 'IPSec Policy 2']} + 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] + ), + IPSecProfile( + name='IPSec Profile 2', + mode=IPSecModeChoices.ESP, + ike_policy=ike_policies[1], + ipsec_policy=ipsec_policies[1] + ), + IPSecProfile( + name='IPSec Profile 3', + mode=IPSecModeChoices.AH, + ike_policy=ike_policies[2], + ipsec_policy=ipsec_policies[2] + ), + ) + IPSecProfile.objects.bulk_create(ipsec_profiles) + + def test_name(self): + params = {'name': ['IPSec Profile 1', 'IPSec Profile 2']} + 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) From 624fcbff8e66fc2e4def7c446376aa1a8afd0a11 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 22 Nov 2023 09:09:46 -0500 Subject: [PATCH 17/27] Flesh out object templates --- netbox/templates/vpn/ikepolicy.html | 26 +++++++- netbox/templates/vpn/ikeproposal.html | 10 ++- netbox/templates/vpn/ipsecpolicy.html | 18 +++++- netbox/templates/vpn/ipsecprofile.html | 82 ++++++++++++++++++++++--- netbox/templates/vpn/ipsecproposal.html | 10 ++- 5 files changed, 130 insertions(+), 16 deletions(-) diff --git a/netbox/templates/vpn/ikepolicy.html b/netbox/templates/vpn/ikepolicy.html index 1bf49818bae..559ba6d17bf 100644 --- a/netbox/templates/vpn/ikepolicy.html +++ b/netbox/templates/vpn/ikepolicy.html @@ -14,6 +14,10 @@
{% trans "IKE Policy" %}
{% trans "Name" %} {{ object.name }} + + {% trans "Description" %} + {{ object.description|placeholder }} + {% trans "IKE Version" %} {{ object.get_version_display }} @@ -23,8 +27,19 @@
{% trans "IKE Policy" %}
{{ object.get_mode_display }} - {% trans "Description" %} - {{ object.description|placeholder }} + {% trans "Pre-Shared Key" %} + + {{ object.preshared_key|placeholder }} + {% if object.preshared_key %} + + {% endif %} + + + + {% trans "IPSec Profiles" %} + + {{ object.ipsec_profiles.count }} + @@ -39,6 +54,13 @@
{% trans "IKE Policy" %}
+
+
{% trans "Proposals" %}
+
+
{% plugin_full_width_page object %}
diff --git a/netbox/templates/vpn/ikeproposal.html b/netbox/templates/vpn/ikeproposal.html index 6079f077146..33cf60c812d 100644 --- a/netbox/templates/vpn/ikeproposal.html +++ b/netbox/templates/vpn/ikeproposal.html @@ -14,6 +14,10 @@
{% trans "IKE Proposal" %}
{% trans "Name" %} {{ object.name }} + + {% trans "Description" %} + {{ object.description|placeholder }} + {% trans "Authentication method" %} {{ object.get_authentication_method_display }} @@ -35,8 +39,10 @@
{% trans "IKE Proposal" %}
{{ object.sa_lifetime|placeholder }} - {% trans "Description" %} - {{ object.description|placeholder }} + {% trans "IKE Policies" %} + + {{ object.ike_policies.count }} + diff --git a/netbox/templates/vpn/ipsecpolicy.html b/netbox/templates/vpn/ipsecpolicy.html index 8b83438769a..4960d9dd33b 100644 --- a/netbox/templates/vpn/ipsecpolicy.html +++ b/netbox/templates/vpn/ipsecpolicy.html @@ -14,13 +14,19 @@
{% trans "IPSec Policy" %}
{% trans "Name" %} {{ object.name }} + + {% trans "Description" %} + {{ object.description|placeholder }} + {% trans "PFS group" %} {{ object.get_pfs_group_display|placeholder }} - {% trans "Description" %} - {{ object.description|placeholder }} + {% trans "IPSec Profiles" %} + + {{ object.ipsec_profiles.count }} + @@ -35,6 +41,14 @@
{% trans "IPSec Policy" %}
+
+
+
{% trans "Proposals" %}
+
+
{% plugin_full_width_page object %}
diff --git a/netbox/templates/vpn/ipsecprofile.html b/netbox/templates/vpn/ipsecprofile.html index aa65393aea8..07d1c15f066 100644 --- a/netbox/templates/vpn/ipsecprofile.html +++ b/netbox/templates/vpn/ipsecprofile.html @@ -22,23 +22,89 @@
{% trans "IPSec Profile" %}
{% trans "Mode" %} {{ object.get_mode_display }} + +
+ + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/comments.html' %} + {% plugin_left_page object %} + +
+
+
{% trans "IKE Policy" %}
+
+ - + - + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "IKE Policy" %}{% trans "Name" %} {{ object.ike_policy|linkify }}
{% trans "IPSec Policy" %}{% trans "Description" %}{{ object.ike_policy.description|placeholder }}
{% trans "Version" %}{{ object.ike_policy.get_version_display }}
{% trans "Mode" %}{{ object.ike_policy.get_mode_display }}
{% trans "Proposals" %} +
    + {% for proposal in object.ike_policy.proposals.all %} +
  • + {{ proposal }} +
  • + {% endfor %} +
+
{% trans "Pre-Shared Key" %}{% checkmark object.ike_policy.preshared_key %}
{% trans "Certificate" %}{% checkmark object.ike_policy.certificate %}
+
+
+
+
{% trans "IPSec Policy" %}
+
+ + + + + + + + + + + + + + +
{% trans "Name" %} {{ object.ipsec_policy|linkify }}
{% trans "Description" %}{{ object.ipsec_policy.description|placeholder }}
{% trans "Proposals" %} +
    + {% for proposal in object.ipsec_policy.proposals.all %} +
  • + {{ proposal }} +
  • + {% endfor %} +
+
{% trans "PFS Group" %}{{ object.ipsec_policy.get_pfs_group_display }}
- {% include 'inc/panels/tags.html' %} - {% include 'inc/panels/comments.html' %} - {% plugin_left_page object %} -
-
- {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/vpn/ipsecproposal.html b/netbox/templates/vpn/ipsecproposal.html index ad375f4e306..7425eef4345 100644 --- a/netbox/templates/vpn/ipsecproposal.html +++ b/netbox/templates/vpn/ipsecproposal.html @@ -14,6 +14,10 @@
{% trans "IPSec Proposal" %}
{% trans "Name" %} {{ object.name }} + + {% trans "Description" %} + {{ object.description|placeholder }} + {% trans "Encryption algorithm" %} {{ object.get_encryption_algorithm_display }} @@ -31,8 +35,10 @@
{% trans "IPSec Proposal" %}
{{ object.sa_lifetime_data|placeholder }} - {% trans "Description" %} - {{ object.description|placeholder }} + {% trans "IPSec Policies" %} + + {{ object.ipsec_policies.count }} + From c929fa380a6e6f603c828b84a4e49c4285781627 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 22 Nov 2023 09:14:17 -0500 Subject: [PATCH 18/27] Drop certificate field from IKEPolicy --- netbox/templates/vpn/ipsecprofile.html | 4 ---- netbox/vpn/api/serializers.py | 4 ++-- netbox/vpn/forms/bulk_edit.py | 8 ++------ netbox/vpn/forms/bulk_import.py | 2 +- netbox/vpn/forms/model_forms.py | 5 ++--- netbox/vpn/migrations/0001_initial.py | 3 --- netbox/vpn/models/crypto.py | 4 ---- netbox/vpn/tables.py | 7 ++----- 8 files changed, 9 insertions(+), 28 deletions(-) diff --git a/netbox/templates/vpn/ipsecprofile.html b/netbox/templates/vpn/ipsecprofile.html index 07d1c15f066..08fa3074ee9 100644 --- a/netbox/templates/vpn/ipsecprofile.html +++ b/netbox/templates/vpn/ipsecprofile.html @@ -67,10 +67,6 @@
{% trans "IKE Policy" %}
{% trans "Pre-Shared Key" %} {% checkmark object.ike_policy.preshared_key %} - - {% trans "Certificate" %} - {% checkmark object.ike_policy.certificate %} - diff --git a/netbox/vpn/api/serializers.py b/netbox/vpn/api/serializers.py index 30cfc5dce85..1c80d1f34e3 100644 --- a/netbox/vpn/api/serializers.py +++ b/netbox/vpn/api/serializers.py @@ -128,8 +128,8 @@ class IKEPolicySerializer(NetBoxModelSerializer): class Meta: model = IKEPolicy fields = ( - 'id', 'url', 'display', 'name', 'description', 'version', 'mode', 'proposals', 'preshared_key', - 'certificate', 'tags', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'description', 'version', 'mode', 'proposals', 'preshared_key', 'tags', + 'custom_fields', 'created', 'last_updated', ) diff --git a/netbox/vpn/forms/bulk_edit.py b/netbox/vpn/forms/bulk_edit.py index 9a6b87c313e..9dee70418f5 100644 --- a/netbox/vpn/forms/bulk_edit.py +++ b/netbox/vpn/forms/bulk_edit.py @@ -134,10 +134,6 @@ class IKEPolicyBulkEditForm(NetBoxModelBulkEditForm): label=_('Pre-shared key'), required=False ) - certificate = forms.CharField( - label=_('Certificate'), - required=False - ) description = forms.CharField( label=_('Description'), max_length=200, @@ -148,11 +144,11 @@ class IKEPolicyBulkEditForm(NetBoxModelBulkEditForm): model = IKEPolicy fieldsets = ( (None, ( - 'version', 'mode', 'preshared_key', 'certificate', 'description', + 'version', 'mode', 'preshared_key', 'description', )), ) nullable_fields = ( - 'preshared_key', 'certificate', 'description', 'comments', + 'preshared_key', 'description', 'comments', ) diff --git a/netbox/vpn/forms/bulk_import.py b/netbox/vpn/forms/bulk_import.py index a815aa10b9c..e31df31d193 100644 --- a/netbox/vpn/forms/bulk_import.py +++ b/netbox/vpn/forms/bulk_import.py @@ -166,7 +166,7 @@ class IKEPolicyImportForm(NetBoxModelImportForm): class Meta: model = IKEPolicy fields = ( - 'name', 'description', 'version', 'mode', 'proposals', 'preshared_key', 'certificate', 'tags', + 'name', 'description', 'version', 'mode', 'proposals', 'preshared_key', 'tags', ) diff --git a/netbox/vpn/forms/model_forms.py b/netbox/vpn/forms/model_forms.py index 79d02917268..e5cdcd42d8b 100644 --- a/netbox/vpn/forms/model_forms.py +++ b/netbox/vpn/forms/model_forms.py @@ -289,14 +289,13 @@ class IKEPolicyForm(NetBoxModelForm): fieldsets = ( (_('Policy'), ('name', 'description', 'tags')), - (_('Parameters'), ('version', 'mode', 'proposals')), - (_('Authentication'), ('preshared_key', 'certificate')), + (_('Parameters'), ('version', 'mode', 'proposals', 'preshared_key')), ) class Meta: model = IKEPolicy fields = [ - 'name', 'description', 'version', 'mode', 'proposals', 'preshared_key', 'certificate', 'tags', + 'name', 'description', 'version', 'mode', 'proposals', 'preshared_key', 'tags', ] diff --git a/netbox/vpn/migrations/0001_initial.py b/netbox/vpn/migrations/0001_initial.py index 46055e9859f..ac0658b7414 100644 --- a/netbox/vpn/migrations/0001_initial.py +++ b/netbox/vpn/migrations/0001_initial.py @@ -1,5 +1,3 @@ -# Generated by Django 4.2.7 on 2023-11-21 13:55 - from django.db import migrations, models import django.db.models.deletion import taggit.managers @@ -30,7 +28,6 @@ class Migration(migrations.Migration): ('version', models.PositiveSmallIntegerField(default=2)), ('mode', models.CharField()), ('preshared_key', models.TextField(blank=True)), - ('certificate', models.TextField(blank=True)), ], options={ 'verbose_name': 'IKE policy', diff --git a/netbox/vpn/models/crypto.py b/netbox/vpn/models/crypto.py index 84a010a3e26..1954dc6a01d 100644 --- a/netbox/vpn/models/crypto.py +++ b/netbox/vpn/models/crypto.py @@ -98,10 +98,6 @@ class IKEPolicy(NetBoxModel): verbose_name=_('pre-shared key'), blank=True ) - certificate = models.TextField( - verbose_name=_('certificate'), - blank=True - ) clone_fields = ( 'version', 'mode', 'proposals', diff --git a/netbox/vpn/tables.py b/netbox/vpn/tables.py index b375674c9cc..23a1481fb19 100644 --- a/netbox/vpn/tables.py +++ b/netbox/vpn/tables.py @@ -146,9 +146,6 @@ class IKEPolicyTable(NetBoxTable): preshared_key = tables.Column( verbose_name=_('Pre-shared Key') ) - certificate = tables.Column( - verbose_name=_('Certificate') - ) tags = columns.TagColumn( url_name='vpn:ikepolicy_list' ) @@ -156,8 +153,8 @@ class IKEPolicyTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = IKEPolicy fields = ( - 'pk', 'id', 'name', 'version', 'mode', 'proposals', 'preshared_key', 'certificate', 'description', 'tags', - 'created', 'last_updated', + 'pk', 'id', 'name', 'version', 'mode', 'proposals', 'preshared_key', 'description', 'tags', 'created', + 'last_updated', ) default_columns = ( 'pk', 'name', 'version', 'mode', 'proposals', 'description', From 667bebdd046ae4f9c48864f33e508d95384f9eda Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 22 Nov 2023 11:00:16 -0500 Subject: [PATCH 19/27] Add feature & model docs --- docs/features/vpn-tunnels.md | 49 ++++++++++++++++++++++++++++ docs/models/vpn/ikepolicy.md | 25 ++++++++++++++ docs/models/vpn/ikeproposal.md | 39 ++++++++++++++++++++++ docs/models/vpn/ipsecpolicy.md | 17 ++++++++++ docs/models/vpn/ipsecprofile.md | 21 ++++++++++++ docs/models/vpn/ipsecproposal.md | 25 ++++++++++++++ docs/models/vpn/tunnel.md | 36 ++++++++++++++++++++ docs/models/vpn/tunneltermination.md | 30 +++++++++++++++++ mkdocs.yml | 9 +++++ 9 files changed, 251 insertions(+) create mode 100644 docs/features/vpn-tunnels.md create mode 100644 docs/models/vpn/ikepolicy.md create mode 100644 docs/models/vpn/ikeproposal.md create mode 100644 docs/models/vpn/ipsecpolicy.md create mode 100644 docs/models/vpn/ipsecprofile.md create mode 100644 docs/models/vpn/ipsecproposal.md create mode 100644 docs/models/vpn/tunnel.md create mode 100644 docs/models/vpn/tunneltermination.md diff --git a/docs/features/vpn-tunnels.md b/docs/features/vpn-tunnels.md new file mode 100644 index 00000000000..a89265ec2db --- /dev/null +++ b/docs/features/vpn-tunnels.md @@ -0,0 +1,49 @@ +# Tunnels + +NetBox can model private tunnels formed among virtual termination points across your network. Typical tunnel implementations include GRE, IP-in-IP, or IPSec. A tunnel may be terminated to two or more device or virtual machine interfaces. + +```mermaid +flowchart TD + Termination1[TunnelTermination] + Termination2[TunnelTermination] + Interface1[Interface] + Interface2[Interface] + Tunnel --> Termination1 & Termination2 + Termination1 --> Interface1 + Termination2 --> Interface2 + Interface1 --> Device + Interface2 --> VirtualMachine + +click Tunnel "../../models/vpn/tunnel/" +click TunnelTermination1 "../../models/vpn/tunneltermination/" +click TunnelTermination2 "../../models/vpn/tunneltermination/" +``` + +# IPSec & IKE + +NetBox includes robust support for modeling IPSec & IKE policies. These are used to define encryption and authentication parameters for IPSec tunnels. + +```mermaid +flowchart TD + subgraph IKEProposals[Proposals] + IKEProposal1[IKEProposal] + IKEProposal2[IKEProposal] + end + subgraph IPSecProposals[Proposals] + IPSecProposal1[IPSecProposal] + IPSecProposal2[IPSecProposal] + end + IKEProposals --> IKEPolicy + IPSecProposals --> IPSecPolicy + IKEPolicy & IPSecPolicy--> IPSecProfile + IPSecProfile --> Tunnel + +click IKEProposal1 "../../models/vpn/ikeproposal/" +click IKEProposal2 "../../models/vpn/ikeproposal/" +click IKEPolicy "../../models/vpn/ikepolicy/" +click IPSecProposal1 "../../models/vpn/ipsecproposal/" +click IPSecProposal2 "../../models/vpn/ipsecproposal/" +click IPSecPolicy "../../models/vpn/ipsecpolicy/" +click IPSecProfile "../../models/vpn/ipsecprofile/" +click Tunnel "../../models/vpn/tunnel/" +``` diff --git a/docs/models/vpn/ikepolicy.md b/docs/models/vpn/ikepolicy.md new file mode 100644 index 00000000000..7b739072b34 --- /dev/null +++ b/docs/models/vpn/ikepolicy.md @@ -0,0 +1,25 @@ +# IKE Policies + +An [Internet Key Exhcnage (IKE)](https://en.wikipedia.org/wiki/Internet_Key_Exchange) policy defines an IKE version, mode, and set of [proposals](./ikeproposal.md) to be used in IKE negotiation. These policies are referenced by [IPSec profiles](./ipsecprofile.md). + +## Fields + +### Name + +The unique user-assigned name for the policy. + +### Version + +The IKE version employed (v1 or v2). + +### Mode + +The IKE mode employed (main or aggressive). + +### Proposals + +One or more [IKE proposals](./ikeproposal.md) supported for use by this policy. + +### Pre-shared Key + +A pre-shared secret key associated with this policy (optional). diff --git a/docs/models/vpn/ikeproposal.md b/docs/models/vpn/ikeproposal.md new file mode 100644 index 00000000000..dd8d7533065 --- /dev/null +++ b/docs/models/vpn/ikeproposal.md @@ -0,0 +1,39 @@ +# IKE Proposals + +An [Internet Key Exhcnage (IKE)](https://en.wikipedia.org/wiki/Internet_Key_Exchange) proposal defines a set of parameters used to establish a secure bidirectional connection across an untrusted medium, such as the Internet. IKE proposals defined in NetBox can be referenced by [IKE policies](./ikepolicy.md), which are in turn employed by [IPSec profiles](./ipsecprofile.md). + +!!! note + Some platforms refer to IKE proposals as [ISAKMP](https://en.wikipedia.org/wiki/Internet_Security_Association_and_Key_Management_Protocol), which is a framework for authentication and key exchange which employs IKE. + +## Fields + +### Name + +The unique user-assigned name for the proposal. + +### Authentication Method + +The strategy employed for authenticating the IKE peer. Available options are listed below. + +| Name | +|----------------| +| Pre-shared key | +| Certificate | +| RSA signature | +| DSA signature | + +### Encryption Algorithm + +The protocol employed for data encryption. Options include DES, 3DES, and various flavors of AES. + +### Authentication Algorithm + +The mechanism employed to ensure data integrity. Options include MD5 and SHA HMAC implementations. + +### Group + +The [Diffie-Hellman group](https://en.wikipedia.org/wiki/Diffie%E2%80%93Hellman_key_exchange) supported by the proposal. Group IDs are [managed by IANA](https://www.iana.org/assignments/ikev2-parameters/ikev2-parameters.xhtml#ikev2-parameters-8). + +### SA Lifetime + +The maximum lifetime for the IKE security association (SA), in seconds. diff --git a/docs/models/vpn/ipsecpolicy.md b/docs/models/vpn/ipsecpolicy.md new file mode 100644 index 00000000000..3283d3b23be --- /dev/null +++ b/docs/models/vpn/ipsecpolicy.md @@ -0,0 +1,17 @@ +# IPSec Policy + +An [IPSec](https://en.wikipedia.org/wiki/IPsec) policy defines a set of [proposals](./ikeproposal.md) to be used in the formation of IPSec tunnels. A perfect forward secrecy (PFS) group may optionally also be defined. These policies are referenced by [IPSec profiles](./ipsecprofile.md). + +## Fields + +### Name + +The unique user-assigned name for the policy. + +### Proposals + +One or more [IPSec proposals](./ipsecproposal.md) supported for use by this policy. + +### PFS Group + +The [perfect forward secrecy (PFS)](https://en.wikipedia.org/wiki/Forward_secrecy) group supported by this policy (optional). diff --git a/docs/models/vpn/ipsecprofile.md b/docs/models/vpn/ipsecprofile.md new file mode 100644 index 00000000000..1ad1ce7d537 --- /dev/null +++ b/docs/models/vpn/ipsecprofile.md @@ -0,0 +1,21 @@ +# IPSec Profile + +An [IPSec](https://en.wikipedia.org/wiki/IPsec) profile defines an [IKE policy](./ikepolicy.md), [IPSec policy](./ipsecpolicy.md), and IPSec mode used for establishing an IPSec tunnel. + +## Fields + +### Name + +The unique user-assigned name for the profile. + +### Mode + +The IPSec mode employed by the profile: Encapsulating Security Payload (ESP) or Authentication Header (AH). + +### IKE Policy + +The [IKE policy](./ikepolicy.md) associated with the profile. + +### IPSec Policy + +The [IPSec policy](./ipsecpolicy.md) associated with the profile. diff --git a/docs/models/vpn/ipsecproposal.md b/docs/models/vpn/ipsecproposal.md new file mode 100644 index 00000000000..d061b153543 --- /dev/null +++ b/docs/models/vpn/ipsecproposal.md @@ -0,0 +1,25 @@ +# IPSec Proposal + +An [IPSec](https://en.wikipedia.org/wiki/IPsec) proposal defines a set of parameters used in negotiating security associations for IPSec tunnels. IPSec proposals defined in NetBox can be referenced by [IPSec policies](./ipsecpolicy.md), which are in turn employed by [IPSec profiles](./ipsecprofile.md). + +## Fields + +### Name + +The unique user-assigned name for the proposal. + +### Encryption Algorithm + +The protocol employed for data encryption. Options include DES, 3DES, and various flavors of AES. + +### Authentication Algorithm + +The mechanism employed to ensure data integrity. Options include MD5 and SHA HMAC implementations. + +### SA Lifetime (Seconds) + +The maximum amount of time for which the security association (SA) may be active, in seconds. + +### SA Lifetime (Data) + +The maximum amount of data which can be transferred within the security association (SA) before it must be rebuilt, in kilobytes. diff --git a/docs/models/vpn/tunnel.md b/docs/models/vpn/tunnel.md new file mode 100644 index 00000000000..ebe004da103 --- /dev/null +++ b/docs/models/vpn/tunnel.md @@ -0,0 +1,36 @@ +# Tunnels + +A tunnel represents a private virtual connection established among two or more endpoints across a shared infrastructure by employing protocol encapsulation. Common encapsulation techniques include [Generic Routing Encapsulation (GRE)](https://en.wikipedia.org/wiki/Generic_Routing_Encapsulation), [IP-in-IP](https://en.wikipedia.org/wiki/IP_in_IP), and [IPSec](https://en.wikipedia.org/wiki/IPsec). NetBox supports modeling both peer-to-peer and hub-and-spoke tunnel topologies. + +Device and virtual machine interfaces are associated to tunnels by creating [tunnel terminations](./tunneltermination.md). + +## Fields + +### Name + +A unique name assigned to the tunnel for identification. + +### Status + +The operational status of the tunnel. By default, the following statuses are available: + +| Name | +|----------------| +| Planned | +| Active | +| Disabled | + +!!! tip "Custom tunnel statuses" + Additional tunnel statuses may be defined by setting `Tunnel.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. + +### Encapsulation + +The encapsulation protocol or technique employed to effect the tunnel. NetBox supports GRE, IP-in-IP, and IPSec encapsulations. + +### Tunnel ID + +An optional numeric identifier for the tunnel. + +### IPSec Profile + +For IPSec tunnels, this is the [IPSec Profile](./ipsecprofile.md) employed to negotiate security associations. diff --git a/docs/models/vpn/tunneltermination.md b/docs/models/vpn/tunneltermination.md new file mode 100644 index 00000000000..8bcfd11c456 --- /dev/null +++ b/docs/models/vpn/tunneltermination.md @@ -0,0 +1,30 @@ +# Tunnel Terminations + +A tunnel termination connects a device or virtual machine interface to a [tunnel](./tunnel.md). The tunnel must be created before any terminations may be added. + +## Fields + +### Tunnel + +The [tunnel](./tunnel.md) to which this termination is made. + +### Role + +The functional role of the attached interface. The following options are available: + +| Name | Description | +|-------|--------------------------------------------------| +| Peer | An endpoint in a point-to-point or mesh topology | +| Hub | A central point in a hub-and-spoke topology | +| Spoke | An edge point in a hub-and-spoke topology | + +!!! note + Multiple hub terminations may be attached to a tunnel. + +### Interface + +The device or virtual machine interface terminated to the tunnel. + +### Outside IP + +The public or underlay IP address with which this termination is associated. This is the IP to which peers will route tunneled traffic. diff --git a/mkdocs.yml b/mkdocs.yml index 3e61f922ae6..f927bf38665 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -74,6 +74,7 @@ nav: - Circuits: 'features/circuits.md' - Wireless: 'features/wireless.md' - Virtualization: 'features/virtualization.md' + - VPN Tunnels: 'features/vpn-tunnels.md' - Tenancy: 'features/tenancy.md' - Contacts: 'features/contacts.md' - Search: 'features/search.md' @@ -252,6 +253,14 @@ nav: - ClusterType: 'models/virtualization/clustertype.md' - VMInterface: 'models/virtualization/vminterface.md' - VirtualMachine: 'models/virtualization/virtualmachine.md' + - VPN: + - IKEPolicy: 'models/vpn/ikepolicy.md' + - IKEProposal: 'models/vpn/ikeproposal.md' + - IPSecPolicy: 'models/vpn/ipsecpolicy.md' + - IPSecProfile: 'models/vpn/ipsecprofile.md' + - IPSecProposal: 'models/vpn/ipsecproposal.md' + - Tunnel: 'models/vpn/tunnel.md' + - TunnelTermination: 'models/vpn/tunneltermination.md' - Wireless: - WirelessLAN: 'models/wireless/wirelesslan.md' - WirelessLANGroup: 'models/wireless/wirelesslangroup.md' From 3cf53cef8fbc683e975606262c7b3989b6d8146c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 22 Nov 2023 11:34:37 -0500 Subject: [PATCH 20/27] Include peer terminations on TunnelTermination view --- netbox/templates/vpn/tunneltermination.html | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/netbox/templates/vpn/tunneltermination.html b/netbox/templates/vpn/tunneltermination.html index 178b97ef56b..458acb41227 100644 --- a/netbox/templates/vpn/tunneltermination.html +++ b/netbox/templates/vpn/tunneltermination.html @@ -16,7 +16,7 @@
{% trans "Tunnel Termination" %}
{% trans "Role" %} - {{ object.get_role_display }} + {% badge object.get_role_display bg_color=object.get_role_color %} @@ -49,6 +49,13 @@
{% trans "Tunnel Termination" %}
+
+
{% trans "Peer Terminations" %}
+
+
{% plugin_full_width_page object %}
From e6c9e13047ad4c83d25a79060e0ff4d493e06ba3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 22 Nov 2023 11:52:47 -0500 Subject: [PATCH 21/27] Workaround for test failure --- netbox/vpn/tests/test_views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/vpn/tests/test_views.py b/netbox/vpn/tests/test_views.py index 88803d921e3..c0c22d1ecd5 100644 --- a/netbox/vpn/tests/test_views.py +++ b/netbox/vpn/tests/test_views.py @@ -65,6 +65,8 @@ def setUpTestData(cls): class TunnelTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = TunnelTermination + # TODO: Workaround for conflict between form field and GFK + validation_excluded_fields = ('interface',) @classmethod def setUpTestData(cls): @@ -112,7 +114,6 @@ def setUpTestData(cls): 'role': TunnelTerminationRoleChoices.ROLE_PEER, 'type': TunnelTerminationTypeChoices.TYPE_DEVICE, 'parent': device.pk, - # TODO: Solve for GFK validation 'interface': interfaces[6].pk, 'tags': [t.pk for t in tags], } From dbed51835439d3cb86efbffd50425b8aa0e2829d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 27 Nov 2023 12:28:29 -0500 Subject: [PATCH 22/27] Review feedback --- netbox/templates/vpn/tunnel.html | 8 ++++++++ netbox/vpn/models/tunnels.py | 4 ++++ netbox/vpn/tables.py | 2 +- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/netbox/templates/vpn/tunnel.html b/netbox/templates/vpn/tunnel.html index 2420a1230ad..2d9274ad905 100644 --- a/netbox/templates/vpn/tunnel.html +++ b/netbox/templates/vpn/tunnel.html @@ -3,6 +3,14 @@ {% load plugins %} {% load i18n %} +{% block extra_controls %} + {% if perms.vpn.add_tunneltermination %} + + {% trans "Add Termination" %} + + {% endif %} +{% endblock %} + {% block content %}
diff --git a/netbox/vpn/models/tunnels.py b/netbox/vpn/models/tunnels.py index 6603de87cf4..377f39706cc 100644 --- a/netbox/vpn/models/tunnels.py +++ b/netbox/vpn/models/tunnels.py @@ -103,6 +103,10 @@ class TunnelTermination(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ChangeLo null=True ) + prerequisite_models = ( + 'vpn.Tunnel', + ) + class Meta: ordering = ('tunnel', 'role', 'pk') constraints = ( diff --git a/netbox/vpn/tables.py b/netbox/vpn/tables.py index 23a1481fb19..5e7d187c653 100644 --- a/netbox/vpn/tables.py +++ b/netbox/vpn/tables.py @@ -213,7 +213,7 @@ class IPSecPolicyTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = IPSecPolicy fields = ( - 'pk', 'id', 'name', 'proposals', 'pfs_group', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'proposals', 'pfs_group', 'description', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'proposals', 'pfs_group', 'description', From dda4071b3ac8090b881a42d175be92cd3b88bb4d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 27 Nov 2023 13:05:18 -0500 Subject: [PATCH 23/27] Rename interface to termination on CableTermination --- netbox/templates/vpn/tunneltermination.html | 8 ++-- netbox/vpn/api/serializers.py | 12 +++--- netbox/vpn/forms/bulk_import.py | 18 ++++----- netbox/vpn/forms/model_forms.py | 44 ++++++++++----------- netbox/vpn/migrations/0001_initial.py | 6 +-- netbox/vpn/models/tunnels.py | 26 ++++++------ netbox/vpn/tables.py | 16 ++++---- netbox/vpn/tests/test_api.py | 18 ++++----- netbox/vpn/tests/test_filtersets.py | 6 +-- netbox/vpn/tests/test_views.py | 14 +++---- 10 files changed, 84 insertions(+), 84 deletions(-) diff --git a/netbox/templates/vpn/tunneltermination.html b/netbox/templates/vpn/tunneltermination.html index 458acb41227..6f4e83ce071 100644 --- a/netbox/templates/vpn/tunneltermination.html +++ b/netbox/templates/vpn/tunneltermination.html @@ -20,17 +20,17 @@
{% trans "Tunnel Termination" %}
- {% if object.interface.device %} + {% if object.termination.device %} {% trans "Device" %} - {% elif object.interface.virtual_machine %} + {% elif object.termination.virtual_machine %} {% trans "Virtual Machine" %} {% endif %} - {{ object.interface.parent_object|linkify }} + {{ object.termination.parent_object|linkify }} {% trans "Interface" %} - {{ object.interface|linkify }} + {{ object.termination|linkify }} {% trans "Outside IP" %} diff --git a/netbox/vpn/api/serializers.py b/netbox/vpn/api/serializers.py index 1c80d1f34e3..881d3d0c063 100644 --- a/netbox/vpn/api/serializers.py +++ b/netbox/vpn/api/serializers.py @@ -58,10 +58,10 @@ class TunnelTerminationSerializer(NetBoxModelSerializer): role = ChoiceField( choices=TunnelTerminationRoleChoices ) - interface_type = ContentTypeField( + termination_type = ContentTypeField( queryset=ContentType.objects.all() ) - interface = serializers.SerializerMethodField( + termination = serializers.SerializerMethodField( read_only=True ) outside_ip = NestedIPAddressSerializer( @@ -72,15 +72,15 @@ class TunnelTerminationSerializer(NetBoxModelSerializer): class Meta: model = TunnelTermination fields = ( - 'id', 'url', 'display', 'tunnel', 'role', 'interface_type', 'interface_id', 'interface', 'outside_ip', + '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_interface(self, obj): - serializer = get_serializer_for_model(obj.interface, prefix=NESTED_SERIALIZER_PREFIX) + def get_termination(self, obj): + serializer = get_serializer_for_model(obj.termination, prefix=NESTED_SERIALIZER_PREFIX) context = {'request': self.context['request']} - return serializer(obj.interface, context=context).data + return serializer(obj.termination, context=context).data class IKEProposalSerializer(NetBoxModelSerializer): diff --git a/netbox/vpn/forms/bulk_import.py b/netbox/vpn/forms/bulk_import.py index e31df31d193..b912153d2a7 100644 --- a/netbox/vpn/forms/bulk_import.py +++ b/netbox/vpn/forms/bulk_import.py @@ -78,12 +78,12 @@ class TunnelTerminationImportForm(NetBoxModelImportForm): to_field_name='name', help_text=_('Parent VM of assigned interface') ) - interface = CSVModelChoiceField( - label=_('Interface'), + termination = CSVModelChoiceField( + label=_('Termination'), queryset=Interface.objects.none(), # Can also refer to VMInterface required=False, to_field_name='name', - help_text=_('Assigned interface') + help_text=_('Device or virtual machine interface') ) outside_ip = CSVModelChoiceField( label=_('Outside IP'), @@ -103,21 +103,21 @@ def __init__(self, data=None, *args, **kwargs): if data: - # Limit interface queryset by assigned device/VM + # Limit termination queryset by assigned device/VM if data.get('device'): - self.fields['interface'].queryset = Interface.objects.filter( + self.fields['termination'].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( + 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): - # Set interface assignment - if self.cleaned_data.get('interface'): - self.instance.interface = self.cleaned_data['interface'] + # Assign termination object + if self.cleaned_data.get('termination'): + self.instance.termination = self.cleaned_data['termination'] return super().save(*args, **kwargs) diff --git a/netbox/vpn/forms/model_forms.py b/netbox/vpn/forms/model_forms.py index e5cdcd42d8b..35fa2cad3ae 100644 --- a/netbox/vpn/forms/model_forms.py +++ b/netbox/vpn/forms/model_forms.py @@ -65,7 +65,7 @@ class TunnelCreateForm(TunnelForm): selector=True, label=_('Device') ) - termination1_interface = DynamicModelChoiceField( + termination1_termination = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, label=_('Interface'), @@ -100,7 +100,7 @@ class TunnelCreateForm(TunnelForm): selector=True, label=_('Device') ) - termination2_interface = DynamicModelChoiceField( + termination2_termination = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, label=_('Interface'), @@ -122,11 +122,11 @@ class TunnelCreateForm(TunnelForm): (_('Security'), ('ipsec_profile',)), (_('Tenancy'), ('tenant_group', 'tenant')), (_('First Termination'), ( - 'termination1_role', 'termination1_type', 'termination1_parent', 'termination1_interface', + 'termination1_role', 'termination1_type', 'termination1_parent', 'termination1_termination', 'termination1_outside_ip', )), (_('Second Termination'), ( - 'termination2_role', 'termination2_type', 'termination2_parent', 'termination2_interface', + 'termination2_role', 'termination2_type', 'termination2_parent', 'termination2_termination', 'termination2_outside_ip', )), ) @@ -137,8 +137,8 @@ def __init__(self, *args, initial=None, **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_interface'].queryset = VMInterface.objects.all() - self.fields['termination1_interface'].widget.add_query_params({ + 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({ @@ -148,8 +148,8 @@ def __init__(self, *args, initial=None, **kwargs): 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_interface'].queryset = VMInterface.objects.all() - self.fields['termination2_interface'].widget.add_query_params({ + 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({ @@ -162,7 +162,7 @@ def clean(self): # Validate attributes for each termination (if any) for term in ('termination1', 'termination2'): required_parameters = ( - f'{term}_role', f'{term}_parent', f'{term}_interface', + f'{term}_role', f'{term}_parent', f'{term}_termination', ) parameters = ( *required_parameters, @@ -179,20 +179,20 @@ def save(self, *args, **kwargs): instance = super().save(*args, **kwargs) # Create first termination - if self.cleaned_data['termination1_interface']: + if self.cleaned_data['termination1_termination']: TunnelTermination.objects.create( tunnel=instance, role=self.cleaned_data['termination1_role'], - interface=self.cleaned_data['termination1_interface'], + termination=self.cleaned_data['termination1_termination'], outside_ip=self.cleaned_data['termination1_outside_ip'], ) # Create second termination, if defined - if self.cleaned_data['termination2_interface']: + if self.cleaned_data['termination2_termination']: TunnelTermination.objects.create( tunnel=instance, role=self.cleaned_data['termination2_role'], - interface=self.cleaned_data['termination2_interface'], + termination=self.cleaned_data['termination2_termination'], outside_ip=self.cleaned_data.get('termination1_outside_ip'), ) @@ -213,7 +213,7 @@ class TunnelTerminationForm(NetBoxModelForm): selector=True, label=_('Device') ) - interface = DynamicModelChoiceField( + termination = DynamicModelChoiceField( queryset=Interface.objects.all(), label=_('Interface'), query_params={ @@ -230,13 +230,13 @@ class TunnelTerminationForm(NetBoxModelForm): ) fieldsets = ( - (None, ('tunnel', 'role', 'type', 'parent', 'interface', 'outside_ip', 'tags')), + (None, ('tunnel', 'role', 'type', 'parent', 'termination', 'outside_ip', 'tags')), ) class Meta: model = TunnelTermination fields = [ - 'tunnel', 'role', 'interface', 'outside_ip', 'tags', + 'tunnel', 'role', 'termination', 'outside_ip', 'tags', ] def __init__(self, *args, initial=None, **kwargs): @@ -245,8 +245,8 @@ def __init__(self, *args, initial=None, **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['interface'].queryset = VMInterface.objects.all() - self.fields['interface'].widget.add_query_params({ + 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({ @@ -254,14 +254,14 @@ def __init__(self, *args, initial=None, **kwargs): }) if self.instance.pk: - self.fields['parent'].initial = self.instance.interface.parent_object - self.fields['interface'].initial = self.instance.interface + self.fields['parent'].initial = self.instance.termination.parent_object + self.fields['termination'].initial = self.instance.termination def clean(self): super().clean() - # Assign the interface - self.instance.interface = self.cleaned_data.get('interface') + # Set the terminated object + self.instance.termination = self.cleaned_data.get('termination') class IKEProposalForm(NetBoxModelForm): diff --git a/netbox/vpn/migrations/0001_initial.py b/netbox/vpn/migrations/0001_initial.py index ac0658b7414..f5d9ae0c18c 100644 --- a/netbox/vpn/migrations/0001_initial.py +++ b/netbox/vpn/migrations/0001_initial.py @@ -104,8 +104,8 @@ class Migration(migrations.Migration): ('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)), - ('interface_id', models.PositiveBigIntegerField(blank=True, null=True)), - ('interface_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')), + ('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')), @@ -181,6 +181,6 @@ class Migration(migrations.Migration): ), migrations.AddConstraint( model_name='tunneltermination', - constraint=models.UniqueConstraint(fields=('interface_type', 'interface_id'), name='vpn_tunneltermination_interface', violation_error_message='An interface may be terminated to only one tunnel at a time.'), + 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/models/tunnels.py b/netbox/vpn/models/tunnels.py index 377f39706cc..f7390d0b471 100644 --- a/netbox/vpn/models/tunnels.py +++ b/netbox/vpn/models/tunnels.py @@ -82,18 +82,18 @@ class TunnelTermination(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ChangeLo choices=TunnelTerminationRoleChoices, default=TunnelTerminationRoleChoices.ROLE_PEER ) - interface_type = models.ForeignKey( + termination_type = models.ForeignKey( to='contenttypes.ContentType', on_delete=models.PROTECT, related_name='+' ) - interface_id = models.PositiveBigIntegerField( + termination_id = models.PositiveBigIntegerField( blank=True, null=True ) - interface = GenericForeignKey( - ct_field='interface_type', - fk_field='interface_id' + termination = GenericForeignKey( + ct_field='termination_type', + fk_field='termination_id' ) outside_ip = models.OneToOneField( to='ipam.IPAddress', @@ -111,9 +111,9 @@ class Meta: ordering = ('tunnel', 'role', 'pk') constraints = ( models.UniqueConstraint( - fields=('interface_type', 'interface_id'), - name='%(app_label)s_%(class)s_interface', - violation_error_message=_("An interface may be terminated to only one tunnel at a time.") + 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') @@ -131,12 +131,12 @@ def get_role_color(self): def clean(self): super().clean() - # Check that the selected Interface is not already attached to a Tunnel - if getattr(self.interface, 'tunnel_termination', None) and self.interface.tunnel_termination.pk != self.pk: + # 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({ - 'interface': _("Interface {name} is already attached to a tunnel ({tunnel}).").format( - name=self.interface.name, - tunnel=self.interface.tunnel_termination.tunnel + 'termination': _("{name} is already attached to a tunnel ({tunnel}).").format( + name=self.termination.name, + tunnel=self.termination.tunnel_termination.tunnel ) }) diff --git a/netbox/vpn/tables.py b/netbox/vpn/tables.py index 5e7d187c653..a174c5a4397 100644 --- a/netbox/vpn/tables.py +++ b/netbox/vpn/tables.py @@ -59,18 +59,18 @@ class TunnelTerminationTable(TenancyColumnsMixin, NetBoxTable): role = columns.ChoiceFieldColumn( verbose_name=_('Role') ) - interface_parent = tables.Column( - accessor='interface__parent_object', + termination_parent = tables.Column( + accessor='termination__parent_object', linkify=True, orderable=False, verbose_name=_('Host') ) - interface = tables.Column( - verbose_name=_('Interface'), + termination = tables.Column( + verbose_name=_('Termination'), linkify=True ) ip_addresses = tables.ManyToManyColumn( - accessor=tables.A('interface__ip_addresses'), + accessor=tables.A('termination__ip_addresses'), orderable=False, linkify_item=True, verbose_name=_('IP Addresses') @@ -86,10 +86,12 @@ class TunnelTerminationTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = TunnelTermination fields = ( - 'pk', 'id', 'tunnel', 'role', 'interface_parent', 'interface', 'ip_addresses', 'outside_ip', 'tags', + 'pk', 'id', 'tunnel', 'role', 'termination_parent', 'termination', 'ip_addresses', 'outside_ip', 'tags', 'created', 'last_updated', ) - default_columns = ('pk', 'id', 'tunnel', 'role', 'interface_parent', 'interface', 'ip_addresses', 'outside_ip') + default_columns = ( + 'pk', 'id', 'tunnel', 'role', 'termination_parent', 'termination', 'ip_addresses', 'outside_ip', + ) class IKEProposalTable(NetBoxTable): diff --git a/netbox/vpn/tests/test_api.py b/netbox/vpn/tests/test_api.py index cdb127ef1c3..9bfa297ab45 100644 --- a/netbox/vpn/tests/test_api.py +++ b/netbox/vpn/tests/test_api.py @@ -96,17 +96,17 @@ def setUpTestData(cls): TunnelTermination( tunnel=tunnel, role=TunnelTerminationRoleChoices.ROLE_HUB, - interface=interfaces[0] + termination=interfaces[0] ), TunnelTermination( tunnel=tunnel, role=TunnelTerminationRoleChoices.ROLE_HUB, - interface=interfaces[1] + termination=interfaces[1] ), TunnelTermination( tunnel=tunnel, role=TunnelTerminationRoleChoices.ROLE_HUB, - interface=interfaces[2] + termination=interfaces[2] ), ) TunnelTermination.objects.bulk_create(tunnel_terminations) @@ -115,20 +115,20 @@ def setUpTestData(cls): { 'tunnel': tunnel.pk, 'role': TunnelTerminationRoleChoices.ROLE_PEER, - 'interface_type': 'dcim.interface', - 'interface_id': interfaces[3].pk, + 'termination_type': 'dcim.interface', + 'termination_id': interfaces[3].pk, }, { 'tunnel': tunnel.pk, 'role': TunnelTerminationRoleChoices.ROLE_PEER, - 'interface_type': 'dcim.interface', - 'interface_id': interfaces[4].pk, + 'termination_type': 'dcim.interface', + 'termination_id': interfaces[4].pk, }, { 'tunnel': tunnel.pk, 'role': TunnelTerminationRoleChoices.ROLE_PEER, - 'interface_type': 'dcim.interface', - 'interface_id': interfaces[5].pk, + 'termination_type': 'dcim.interface', + 'termination_id': interfaces[5].pk, }, ] diff --git a/netbox/vpn/tests/test_filtersets.py b/netbox/vpn/tests/test_filtersets.py index 9e53d8cd27c..db84cd412a7 100644 --- a/netbox/vpn/tests/test_filtersets.py +++ b/netbox/vpn/tests/test_filtersets.py @@ -147,19 +147,19 @@ def setUpTestData(cls): TunnelTermination( tunnel=tunnels[0], role=TunnelTerminationRoleChoices.ROLE_HUB, - interface=interfaces[0], + termination=interfaces[0], outside_ip=ip_addresses[0] ), TunnelTermination( tunnel=tunnels[1], role=TunnelTerminationRoleChoices.ROLE_SPOKE, - interface=interfaces[1], + termination=interfaces[1], outside_ip=ip_addresses[1] ), TunnelTermination( tunnel=tunnels[2], role=TunnelTerminationRoleChoices.ROLE_PEER, - interface=interfaces[2], + termination=interfaces[2], outside_ip=ip_addresses[2] ), ) diff --git a/netbox/vpn/tests/test_views.py b/netbox/vpn/tests/test_views.py index c0c22d1ecd5..433eca4679e 100644 --- a/netbox/vpn/tests/test_views.py +++ b/netbox/vpn/tests/test_views.py @@ -1,5 +1,3 @@ -from django.contrib.contenttypes.models import ContentType - from dcim.choices import InterfaceTypeChoices from dcim.models import Interface from vpn.choices import * @@ -66,7 +64,7 @@ def setUpTestData(cls): class TunnelTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = TunnelTermination # TODO: Workaround for conflict between form field and GFK - validation_excluded_fields = ('interface',) + validation_excluded_fields = ('termination',) @classmethod def setUpTestData(cls): @@ -92,17 +90,17 @@ def setUpTestData(cls): TunnelTermination( tunnel=tunnel, role=TunnelTerminationRoleChoices.ROLE_HUB, - interface=interfaces[0] + termination=interfaces[0] ), TunnelTermination( tunnel=tunnel, role=TunnelTerminationRoleChoices.ROLE_SPOKE, - interface=interfaces[1] + termination=interfaces[1] ), TunnelTermination( tunnel=tunnel, role=TunnelTerminationRoleChoices.ROLE_SPOKE, - interface=interfaces[2] + termination=interfaces[2] ), ) TunnelTermination.objects.bulk_create(tunnel_terminations) @@ -114,12 +112,12 @@ def setUpTestData(cls): 'role': TunnelTerminationRoleChoices.ROLE_PEER, 'type': TunnelTerminationTypeChoices.TYPE_DEVICE, 'parent': device.pk, - 'interface': interfaces[6].pk, + 'termination': interfaces[6].pk, 'tags': [t.pk for t in tags], } cls.csv_data = ( - "tunnel,role,device,interface", + "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", From 21f1de4fa0d57a85c564136c1b0d4741e0863c93 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 27 Nov 2023 13:27:29 -0500 Subject: [PATCH 24/27] Implement interface filters for TunnelTermination --- netbox/dcim/models/device_components.py | 6 ++ .../virtualization/models/virtualmachines.py | 6 ++ netbox/vpn/filtersets.py | 44 +++++++------- netbox/vpn/tests/test_filtersets.py | 59 +++++++++++++++++-- 4 files changed, 87 insertions(+), 28 deletions(-) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 558505e0c55..2e3395a43e5 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -729,6 +729,12 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd 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='interface' + ) l2vpn_terminations = GenericRelation( to='ipam.L2VPNTermination', content_type_field='assigned_object_type', diff --git a/netbox/virtualization/models/virtualmachines.py b/netbox/virtualization/models/virtualmachines.py index eb6c2a8b0dd..ac7489f859a 100644 --- a/netbox/virtualization/models/virtualmachines.py +++ b/netbox/virtualization/models/virtualmachines.py @@ -291,6 +291,12 @@ 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', content_type_field='assigned_object_type', diff --git a/netbox/vpn/filtersets.py b/netbox/vpn/filtersets.py index 4a2d5956aa6..d601eeeebc3 100644 --- a/netbox/vpn/filtersets.py +++ b/netbox/vpn/filtersets.py @@ -69,28 +69,28 @@ class TunnelTerminationFilterSet(NetBoxModelFilterSet): role = django_filters.MultipleChoiceFilter( choices=TunnelTerminationRoleChoices ) - # 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='interface__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)'), - # ) + 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(), diff --git a/netbox/vpn/tests/test_filtersets.py b/netbox/vpn/tests/test_filtersets.py index db84cd412a7..38d0d2a13d6 100644 --- a/netbox/vpn/tests/test_filtersets.py +++ b/netbox/vpn/tests/test_filtersets.py @@ -3,10 +3,11 @@ from dcim.choices import InterfaceTypeChoices from dcim.models import Interface from ipam.models import IPAddress +from virtualization.models import VMInterface from vpn.choices import * from vpn.filtersets import * from vpn.models import * -from utilities.testing import ChangeLoggedFilterSetTests, create_test_device +from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine class TunnelTestCase(TestCase, ChangeLoggedFilterSetTests): @@ -117,10 +118,21 @@ def setUpTestData(cls): ) 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) @@ -144,6 +156,7 @@ def setUpTestData(cls): Tunnel.objects.bulk_create(tunnels) tunnel_terminations = ( + # Tunnel 1 TunnelTermination( tunnel=tunnels[0], role=TunnelTerminationRoleChoices.ROLE_HUB, @@ -151,16 +164,36 @@ def setUpTestData(cls): outside_ip=ip_addresses[0] ), TunnelTermination( - tunnel=tunnels[1], + tunnel=tunnels[0], role=TunnelTerminationRoleChoices.ROLE_SPOKE, - termination=interfaces[1], + 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[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) @@ -168,12 +201,26 @@ def setUpTestData(cls): 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(), 2) + 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(), 2) + 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_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): From fd432e023d7806cb2ecd632ffc15bc3c124d9ba3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 27 Nov 2023 14:55:53 -0500 Subject: [PATCH 25/27] Add search indexers --- netbox/vpn/search.py | 65 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/netbox/vpn/search.py b/netbox/vpn/search.py index e69de29bb2d..70b0c644f52 100644 --- a/netbox/vpn/search.py +++ b/netbox/vpn/search.py @@ -0,0 +1,65 @@ +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 = ('status', 'encapsulation', 'tenant', 'description') + + +@register_search +class IKEProposalIndex(SearchIndex): + model = models.IKEProposal + fields = ( + ('name', 100), + ('description', 500), + ) + display_attrs = ('description',) + + +@register_search +class IKEPolicyIndex(SearchIndex): + model = models.IKEPolicy + fields = ( + ('name', 100), + ('description', 500), + ) + display_attrs = ('description',) + + +@register_search +class IPSecProposalIndex(SearchIndex): + model = models.IPSecProposal + fields = ( + ('name', 100), + ('description', 500), + ) + display_attrs = ('description',) + + +@register_search +class IPSecPolicyIndex(SearchIndex): + model = models.IPSecPolicy + fields = ( + ('name', 100), + ('description', 500), + ) + display_attrs = ('description',) + + +@register_search +class IPSecProfileIndex(SearchIndex): + model = models.IPSecProfile + fields = ( + ('name', 100), + ('description', 500), + ('comments', 5000), + ) + display_attrs = ('description',) From 9038ed88fc73a8dac5e1b0c294c29ef24cc488ab Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 27 Nov 2023 14:57:57 -0500 Subject: [PATCH 26/27] Misc cleanup --- docs/features/vpn-tunnels.md | 2 +- docs/models/vpn/tunneltermination.md | 2 +- netbox/dcim/models/device_components.py | 5 ----- netbox/templates/vpn/tunnel.html | 20 ++++++++------------ netbox/vpn/api/serializers.py | 2 +- netbox/vpn/filtersets.py | 2 +- netbox/vpn/forms/bulk_edit.py | 3 --- netbox/vpn/forms/bulk_import.py | 2 +- netbox/vpn/tables.py | 2 +- 9 files changed, 14 insertions(+), 26 deletions(-) diff --git a/docs/features/vpn-tunnels.md b/docs/features/vpn-tunnels.md index a89265ec2db..ae6df70c84e 100644 --- a/docs/features/vpn-tunnels.md +++ b/docs/features/vpn-tunnels.md @@ -1,6 +1,6 @@ # Tunnels -NetBox can model private tunnels formed among virtual termination points across your network. Typical tunnel implementations include GRE, IP-in-IP, or IPSec. A tunnel may be terminated to two or more device or virtual machine interfaces. +NetBox can model private tunnels formed among virtual termination points across your network. Typical tunnel implementations include GRE, IP-in-IP, and IPSec. A tunnel may be terminated to two or more device or virtual machine interfaces. ```mermaid flowchart TD diff --git a/docs/models/vpn/tunneltermination.md b/docs/models/vpn/tunneltermination.md index 8bcfd11c456..8400eaa8639 100644 --- a/docs/models/vpn/tunneltermination.md +++ b/docs/models/vpn/tunneltermination.md @@ -21,7 +21,7 @@ The functional role of the attached interface. The following options are availab !!! note Multiple hub terminations may be attached to a tunnel. -### Interface +### Termination The device or virtual machine interface terminated to the tunnel. diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 2e3395a43e5..1df07bb9b5d 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -551,11 +551,6 @@ class BaseInterface(models.Model): blank=True, verbose_name=_('bridge interface') ) - tunnel_terminations = GenericRelation( - to='vpn.TunnelTermination', - content_type_field='interface_type', - object_id_field='interface_id' - ) class Meta: abstract = True diff --git a/netbox/templates/vpn/tunnel.html b/netbox/templates/vpn/tunnel.html index 2d9274ad905..544ffadae32 100644 --- a/netbox/templates/vpn/tunnel.html +++ b/netbox/templates/vpn/tunnel.html @@ -26,6 +26,10 @@
{% trans "Tunnel" %}
{% trans "Status" %} {% badge object.get_status_display bg_color=object.get_status_color %} + + {% trans "Description" %} + {{ object.description|placeholder }} + {% trans "Encapsulation" %} {{ object.get_encapsulation_display }} @@ -34,6 +38,10 @@
{% trans "Tunnel" %}
{% trans "IPSec profile" %} {{ object.ipsec_profile|linkify|placeholder }} + + {% trans "Tunnel ID" %} + {{ object.tunnel_id|placeholder }} + {% trans "Tenant" %} @@ -43,18 +51,6 @@
{% trans "Tunnel" %}
{{ object.tenant|linkify|placeholder }} - - {% trans "Pre-shared key" %} - {{ object.preshared_key|placeholder }} - - - {% trans "Tunnel ID" %} - {{ object.tunnel_id|placeholder }} - - - {% trans "Description" %} - {{ object.description|placeholder }} -
diff --git a/netbox/vpn/api/serializers.py b/netbox/vpn/api/serializers.py index 881d3d0c063..1a517fe5916 100644 --- a/netbox/vpn/api/serializers.py +++ b/netbox/vpn/api/serializers.py @@ -148,7 +148,7 @@ class Meta: model = IPSecProposal fields = ( 'id', 'url', 'display', 'name', 'description', 'encryption_algorithm', 'authentication_algorithm', - 'sa_lifetime_data', 'sa_lifetime_seconds', 'tags', 'custom_fields', 'created', 'last_updated', + 'sa_lifetime_seconds', 'sa_lifetime_data', 'tags', 'custom_fields', 'created', 'last_updated', ) diff --git a/netbox/vpn/filtersets.py b/netbox/vpn/filtersets.py index d601eeeebc3..8a474b987a4 100644 --- a/netbox/vpn/filtersets.py +++ b/netbox/vpn/filtersets.py @@ -145,7 +145,7 @@ class IKEPolicyFilterSet(NetBoxModelFilterSet): class Meta: model = IKEPolicy - fields = ['id', 'name'] + fields = ['id', 'name', 'preshared_key'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/vpn/forms/bulk_edit.py b/netbox/vpn/forms/bulk_edit.py index 9dee70418f5..a7b097b5c94 100644 --- a/netbox/vpn/forms/bulk_edit.py +++ b/netbox/vpn/forms/bulk_edit.py @@ -70,9 +70,6 @@ class TunnelTerminationBulkEditForm(NetBoxModelBulkEditForm): ) model = TunnelTermination - fieldsets = ( - (None, ('role',)), - ) class IKEProposalBulkEditForm(NetBoxModelBulkEditForm): diff --git a/netbox/vpn/forms/bulk_import.py b/netbox/vpn/forms/bulk_import.py index b912153d2a7..5b42cc761e4 100644 --- a/netbox/vpn/forms/bulk_import.py +++ b/netbox/vpn/forms/bulk_import.py @@ -190,7 +190,7 @@ class Meta: class IPSecPolicyImportForm(NetBoxModelImportForm): pfs_group = CSVChoiceField( - label=_('PFS group'), + label=_('Diffie-Hellman group for Perfect Forward Secrecy'), choices=DHGroupChoices ) proposals = CSVModelMultipleChoiceField( diff --git a/netbox/vpn/tables.py b/netbox/vpn/tables.py index a174c5a4397..304467586e4 100644 --- a/netbox/vpn/tables.py +++ b/netbox/vpn/tables.py @@ -48,7 +48,7 @@ class Meta(NetBoxTable.Meta): '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') + default_columns = ('pk', 'name', 'status', 'encapsulation', 'ipsec_profile', 'tenant', 'terminations_count') class TunnelTerminationTable(TenancyColumnsMixin, NetBoxTable): From 2a645580274e6d772db3204de3c6af9c118aa459 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 27 Nov 2023 16:01:17 -0500 Subject: [PATCH 27/27] Add termination_type filter --- netbox/vpn/filtersets.py | 1 + netbox/vpn/tests/test_filtersets.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/netbox/vpn/filtersets.py b/netbox/vpn/filtersets.py index 8a474b987a4..c0bd140c326 100644 --- a/netbox/vpn/filtersets.py +++ b/netbox/vpn/filtersets.py @@ -69,6 +69,7 @@ class TunnelTerminationFilterSet(NetBoxModelFilterSet): role = django_filters.MultipleChoiceFilter( choices=TunnelTerminationRoleChoices ) + termination_type = ContentTypeFilter() interface = django_filters.ModelMultipleChoiceFilter( field_name='interface__name', queryset=Interface.objects.all(), diff --git a/netbox/vpn/tests/test_filtersets.py b/netbox/vpn/tests/test_filtersets.py index 38d0d2a13d6..966717f4a99 100644 --- a/netbox/vpn/tests/test_filtersets.py +++ b/netbox/vpn/tests/test_filtersets.py @@ -209,6 +209,12 @@ 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]}