From 18422e1d268cff15d58fd93a709f5136ce43ad80 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 22 Nov 2023 13:49:26 -0500 Subject: [PATCH 01/22] Closes #14326: Move form mixins (#14327) * Move form mixins from extras app to netbox app * Remove obsolete extras/admin.py --- netbox/core/forms/filtersets.py | 2 +- netbox/dcim/forms/bulk_create.py | 4 ++-- netbox/extras/admin.py | 2 -- netbox/extras/forms/__init__.py | 1 - netbox/extras/forms/filtersets.py | 2 +- netbox/netbox/forms/base.py | 2 +- netbox/{extras => netbox}/forms/mixins.py | 0 netbox/users/forms/filtersets.py | 8 +++----- 8 files changed, 8 insertions(+), 13 deletions(-) delete mode 100644 netbox/extras/admin.py rename netbox/{extras => netbox}/forms/mixins.py (100%) diff --git a/netbox/core/forms/filtersets.py b/netbox/core/forms/filtersets.py index a567a9fed..14f0fb6ed 100644 --- a/netbox/core/forms/filtersets.py +++ b/netbox/core/forms/filtersets.py @@ -4,8 +4,8 @@ from django.utils.translation import gettext_lazy as _ from core.choices import * from core.models import * -from extras.forms.mixins import SavedFiltersMixin from netbox.forms import NetBoxModelFilterSetForm +from netbox.forms.mixins import SavedFiltersMixin from netbox.utils import get_data_backend_choices from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm from utilities.forms.fields import ContentTypeChoiceField, DynamicModelMultipleChoiceField diff --git a/netbox/dcim/forms/bulk_create.py b/netbox/dcim/forms/bulk_create.py index 02aa5a3e4..2a84a9a51 100644 --- a/netbox/dcim/forms/bulk_create.py +++ b/netbox/dcim/forms/bulk_create.py @@ -1,9 +1,9 @@ from django import forms +from django.utils.translation import gettext_lazy as _ from dcim.models import * -from django.utils.translation import gettext_lazy as _ -from extras.forms import CustomFieldsMixin from extras.models import Tag +from netbox.forms.mixins import CustomFieldsMixin from utilities.forms import BootstrapMixin, form_from_model from utilities.forms.fields import DynamicModelMultipleChoiceField, ExpandableNameField from .object_create import ComponentCreateForm diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py deleted file mode 100644 index 6e82ffc75..000000000 --- a/netbox/extras/admin.py +++ /dev/null @@ -1,2 +0,0 @@ -# TODO: Removing this import triggers an import loop due to how form mixins are currently organized -from .forms import ConfigRevisionForm diff --git a/netbox/extras/forms/__init__.py b/netbox/extras/forms/__init__.py index e203bee46..8bebaeec2 100644 --- a/netbox/extras/forms/__init__.py +++ b/netbox/extras/forms/__init__.py @@ -3,5 +3,4 @@ from .filtersets import * from .bulk_edit import * from .bulk_import import * from .misc import * -from .mixins import * from .scripts import * diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 5da3ba1e6..28aefa685 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -7,6 +7,7 @@ from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site from extras.choices import * from extras.models import * from netbox.forms.base import NetBoxModelFilterSetForm +from netbox.forms.mixins import SavedFiltersMixin from tenancy.models import Tenant, TenantGroup from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice from utilities.forms.fields import ( @@ -14,7 +15,6 @@ from utilities.forms.fields import ( ) from utilities.forms.widgets import APISelectMultiple, DateTimePicker from virtualization.models import Cluster, ClusterGroup, ClusterType -from .mixins import * __all__ = ( 'ConfigContextFilterForm', diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index b51efe9c0..5b13dc531 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -4,11 +4,11 @@ from django.db.models import Q from django.utils.translation import gettext_lazy as _ from extras.choices import * -from extras.forms.mixins import CustomFieldsMixin, SavedFiltersMixin, TagsMixin from extras.models import CustomField, Tag from utilities.forms import CSVModelForm from utilities.forms.fields import CSVModelMultipleChoiceField, DynamicModelMultipleChoiceField from utilities.forms.mixins import BootstrapMixin, CheckLastUpdatedMixin +from .mixins import CustomFieldsMixin, SavedFiltersMixin, TagsMixin __all__ = ( 'NetBoxModelForm', diff --git a/netbox/extras/forms/mixins.py b/netbox/netbox/forms/mixins.py similarity index 100% rename from netbox/extras/forms/mixins.py rename to netbox/netbox/forms/mixins.py diff --git a/netbox/users/forms/filtersets.py b/netbox/users/forms/filtersets.py index ff56cbc4c..4ae2bd729 100644 --- a/netbox/users/forms/filtersets.py +++ b/netbox/users/forms/filtersets.py @@ -1,14 +1,12 @@ from django import forms -from extras.forms.mixins import SavedFiltersMixin -from utilities.forms import FilterForm -from users.models import Token from django.contrib.auth import get_user_model from django.contrib.auth.models import Group from django.utils.translation import gettext_lazy as _ from netbox.forms import NetBoxModelFilterSetForm -from users.models import NetBoxGroup, NetBoxUser, ObjectPermission -from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES +from netbox.forms.mixins import SavedFiltersMixin +from users.models import NetBoxGroup, NetBoxUser, ObjectPermission, Token +from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm from utilities.forms.fields import DynamicModelMultipleChoiceField from utilities.forms.widgets import DateTimePicker From 975a647d9a29572c4e1a1b1a8f8961631305577e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 27 Nov 2023 16:09:05 -0500 Subject: [PATCH 02/22] Closes #14312: Move ConfigRevision to core (#14328) * Move ConfigRevision model & write migrations * Move ConfigRevision resources from extras to core * Extend migration to update original content type for ConfigRevision --- netbox/core/filtersets.py | 21 ++++ netbox/core/forms/filtersets.py | 7 ++ netbox/core/forms/model_forms.py | 118 +++++++++++++++++- netbox/core/management/commands/clearcache.py | 2 +- netbox/core/migrations/0009_configrevision.py | 31 +++++ netbox/core/models/__init__.py | 1 + netbox/core/models/config.py | 66 ++++++++++ netbox/core/signals.py | 11 ++ netbox/core/tables/__init__.py | 1 + netbox/core/tables/config.py | 33 +++++ netbox/core/urls.py | 7 ++ netbox/core/views.py | 73 ++++++++++- netbox/extras/filtersets.py | 25 ---- netbox/extras/forms/filtersets.py | 7 -- netbox/extras/forms/model_forms.py | 117 ----------------- .../migrations/0101_move_configrevision.py | 39 ++++++ netbox/extras/models/models.py | 60 +-------- netbox/extras/signals.py | 14 +-- netbox/extras/tables/tables.py | 26 ---- netbox/extras/urls.py | 7 -- netbox/extras/views.py | 69 ---------- netbox/netbox/config/__init__.py | 2 +- netbox/netbox/navigation/menu.py | 6 +- netbox/netbox/tests/test_config.py | 2 +- .../{extras => core}/configrevision.html | 6 +- .../configrevision_restore.html | 6 +- 26 files changed, 417 insertions(+), 340 deletions(-) create mode 100644 netbox/core/migrations/0009_configrevision.py create mode 100644 netbox/core/models/config.py create mode 100644 netbox/core/tables/config.py create mode 100644 netbox/extras/migrations/0101_move_configrevision.py rename netbox/templates/{extras => core}/configrevision.html (96%) rename netbox/templates/{extras => core}/configrevision_restore.html (85%) diff --git a/netbox/core/filtersets.py b/netbox/core/filtersets.py index 410e2e80c..a293b44ec 100644 --- a/netbox/core/filtersets.py +++ b/netbox/core/filtersets.py @@ -9,6 +9,7 @@ from .choices import * from .models import * __all__ = ( + 'ConfigRevisionFilterSet', 'DataFileFilterSet', 'DataSourceFilterSet', 'JobFilterSet', @@ -123,3 +124,23 @@ class JobFilterSet(BaseFilterSet): Q(user__username__icontains=value) | Q(name__icontains=value) ) + + +class ConfigRevisionFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label=_('Search'), + ) + + class Meta: + model = ConfigRevision + fields = [ + 'id', + ] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(comment__icontains=value) + ) diff --git a/netbox/core/forms/filtersets.py b/netbox/core/forms/filtersets.py index 14f0fb6ed..f21bd3f87 100644 --- a/netbox/core/forms/filtersets.py +++ b/netbox/core/forms/filtersets.py @@ -12,6 +12,7 @@ from utilities.forms.fields import ContentTypeChoiceField, DynamicModelMultipleC from utilities.forms.widgets import APISelectMultiple, DateTimePicker __all__ = ( + 'ConfigRevisionFilterForm', 'DataFileFilterForm', 'DataSourceFilterForm', 'JobFilterForm', @@ -123,3 +124,9 @@ class JobFilterForm(SavedFiltersMixin, FilterForm): api_url='/api/users/users/', ) ) + + +class ConfigRevisionFilterForm(SavedFiltersMixin, FilterForm): + fieldsets = ( + (None, ('q', 'filter_id')), + ) diff --git a/netbox/core/forms/model_forms.py b/netbox/core/forms/model_forms.py index e3184acf6..652728734 100644 --- a/netbox/core/forms/model_forms.py +++ b/netbox/core/forms/model_forms.py @@ -1,22 +1,28 @@ import copy +import json from django import forms +from django.conf import settings from django.utils.translation import gettext_lazy as _ from core.forms.mixins import SyncedDataMixin from core.models import * +from netbox.config import get_config, PARAMS from netbox.forms import NetBoxModelForm from netbox.registry import registry from netbox.utils import get_data_backend_choices -from utilities.forms import get_field_value +from utilities.forms import BootstrapMixin, get_field_value from utilities.forms.fields import CommentField from utilities.forms.widgets import HTMXSelect __all__ = ( + 'ConfigRevisionForm', 'DataSourceForm', 'ManagedFileForm', ) +EMPTY_VALUES = ('', None, [], ()) + class DataSourceForm(NetBoxModelForm): type = forms.ChoiceField( @@ -111,3 +117,113 @@ class ManagedFileForm(SyncedDataMixin, NetBoxModelForm): new_file.write(self.cleaned_data['upload_file'].read()) return super().save(*args, **kwargs) + + +class ConfigFormMetaclass(forms.models.ModelFormMetaclass): + + def __new__(mcs, name, bases, attrs): + + # Emulate a declared field for each supported configuration parameter + param_fields = {} + for param in PARAMS: + field_kwargs = { + 'required': False, + 'label': param.label, + 'help_text': param.description, + } + field_kwargs.update(**param.field_kwargs) + param_fields[param.name] = param.field(**field_kwargs) + attrs.update(param_fields) + + return super().__new__(mcs, name, bases, attrs) + + +class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMetaclass): + """ + Form for creating a new ConfigRevision. + """ + + fieldsets = ( + (_('Rack Elevations'), ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH')), + (_('Power'), ('POWERFEED_DEFAULT_VOLTAGE', 'POWERFEED_DEFAULT_AMPERAGE', 'POWERFEED_DEFAULT_MAX_UTILIZATION')), + (_('IPAM'), ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4')), + (_('Security'), ('ALLOWED_URL_SCHEMES',)), + (_('Banners'), ('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM')), + (_('Pagination'), ('PAGINATE_COUNT', 'MAX_PAGE_SIZE')), + (_('Validation'), ('CUSTOM_VALIDATORS', 'PROTECTION_RULES')), + (_('User Preferences'), ('DEFAULT_USER_PREFERENCES',)), + (_('Miscellaneous'), ( + 'MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION', 'MAPS_URL', + )), + (_('Config Revision'), ('comment',)) + ) + + class Meta: + model = ConfigRevision + fields = '__all__' + widgets = { + 'BANNER_LOGIN': forms.Textarea(attrs={'class': 'font-monospace'}), + 'BANNER_MAINTENANCE': forms.Textarea(attrs={'class': 'font-monospace'}), + 'BANNER_TOP': forms.Textarea(attrs={'class': 'font-monospace'}), + 'BANNER_BOTTOM': forms.Textarea(attrs={'class': 'font-monospace'}), + 'CUSTOM_VALIDATORS': forms.Textarea(attrs={'class': 'font-monospace'}), + 'PROTECTION_RULES': forms.Textarea(attrs={'class': 'font-monospace'}), + 'comment': forms.Textarea(), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Append current parameter values to form field help texts and check for static configurations + config = get_config() + for param in PARAMS: + value = getattr(config, param.name) + + # Set the field's initial value, if it can be serialized. (This may not be the case e.g. for + # CUSTOM_VALIDATORS, which may reference Python objects.) + try: + json.dumps(value) + if type(value) in (tuple, list): + self.fields[param.name].initial = ', '.join(value) + else: + self.fields[param.name].initial = value + except TypeError: + pass + + # Check whether this parameter is statically configured (e.g. in configuration.py) + if hasattr(settings, param.name): + self.fields[param.name].disabled = True + self.fields[param.name].help_text = _( + 'This parameter has been defined statically and cannot be modified.' + ) + continue + + # Set the field's help text + help_text = self.fields[param.name].help_text + if help_text: + help_text += '
' # Line break + help_text += _('Current value: {value}').format(value=value or '—') + if value == param.default: + help_text += _(' (default)') + self.fields[param.name].help_text = help_text + + def save(self, commit=True): + instance = super().save(commit=False) + + # Populate JSON data on the instance + instance.data = self.render_json() + + if commit: + instance.save() + + return instance + + def render_json(self): + json = {} + + # Iterate through each field and populate non-empty values + for field_name in self.declared_fields: + if field_name in self.cleaned_data and self.cleaned_data[field_name] not in EMPTY_VALUES: + json[field_name] = self.cleaned_data[field_name] + + return json diff --git a/netbox/core/management/commands/clearcache.py b/netbox/core/management/commands/clearcache.py index dd95013af..9c91efe77 100644 --- a/netbox/core/management/commands/clearcache.py +++ b/netbox/core/management/commands/clearcache.py @@ -1,7 +1,7 @@ from django.core.cache import cache from django.core.management.base import BaseCommand -from extras.models import ConfigRevision +from core.models import ConfigRevision class Command(BaseCommand): diff --git a/netbox/core/migrations/0009_configrevision.py b/netbox/core/migrations/0009_configrevision.py new file mode 100644 index 000000000..e7f817a16 --- /dev/null +++ b/netbox/core/migrations/0009_configrevision.py @@ -0,0 +1,31 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0008_contenttype_proxy'), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + state_operations=[ + migrations.CreateModel( + name='ConfigRevision', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True)), + ('comment', models.CharField(blank=True, max_length=200)), + ('data', models.JSONField(blank=True, null=True)), + ], + options={ + 'verbose_name': 'config revision', + 'verbose_name_plural': 'config revisions', + 'ordering': ['-created'], + }, + ), + ], + # Table will be renamed from extras_configrevision in extras/0101_move_configrevision + database_operations=[], + ), + ] diff --git a/netbox/core/models/__init__.py b/netbox/core/models/__init__.py index c93c392d7..2c30ce02b 100644 --- a/netbox/core/models/__init__.py +++ b/netbox/core/models/__init__.py @@ -1,3 +1,4 @@ +from .config import * from .contenttypes import * from .data import * from .files import * diff --git a/netbox/core/models/config.py b/netbox/core/models/config.py new file mode 100644 index 000000000..6c8e41477 --- /dev/null +++ b/netbox/core/models/config.py @@ -0,0 +1,66 @@ +from django.core.cache import cache +from django.db import models +from django.urls import reverse +from django.utils.translation import gettext, gettext_lazy as _ + +from utilities.querysets import RestrictedQuerySet + +__all__ = ( + 'ConfigRevision', +) + + +class ConfigRevision(models.Model): + """ + An atomic revision of NetBox's configuration. + """ + created = models.DateTimeField( + verbose_name=_('created'), + auto_now_add=True + ) + comment = models.CharField( + verbose_name=_('comment'), + max_length=200, + blank=True + ) + data = models.JSONField( + blank=True, + null=True, + verbose_name=_('configuration data') + ) + + objects = RestrictedQuerySet.as_manager() + + class Meta: + ordering = ['-created'] + verbose_name = _('config revision') + verbose_name_plural = _('config revisions') + + def __str__(self): + if not self.pk: + return gettext('Default configuration') + if self.is_active: + return gettext('Current configuration') + return gettext('Config revision #{id}').format(id=self.pk) + + def __getattr__(self, item): + if item in self.data: + return self.data[item] + return super().__getattribute__(item) + + def get_absolute_url(self): + if not self.pk: + return reverse('core:config') # Default config view + return reverse('core:configrevision', args=[self.pk]) + + def activate(self): + """ + Cache the configuration data. + """ + cache.set('config', self.data, None) + cache.set('config_version', self.pk, None) + activate.alters_data = True + + @property + def is_active(self): + return cache.get('config_version') == self.pk diff --git a/netbox/core/signals.py b/netbox/core/signals.py index a39a87c6a..cd1633a1a 100644 --- a/netbox/core/signals.py +++ b/netbox/core/signals.py @@ -1,5 +1,8 @@ +from django.db.models.signals import post_save from django.dispatch import Signal, receiver +from .models import ConfigRevision + __all__ = ( 'post_sync', 'pre_sync', @@ -19,3 +22,11 @@ def auto_sync(instance, **kwargs): for autosync in AutoSyncRecord.objects.filter(datafile__source=instance).prefetch_related('object'): autosync.object.sync(save=True) + + +@receiver(post_save, sender=ConfigRevision) +def update_config(sender, instance, **kwargs): + """ + Update the cached NetBox configuration when a new ConfigRevision is created. + """ + instance.activate() diff --git a/netbox/core/tables/__init__.py b/netbox/core/tables/__init__.py index 052f68b68..69f9d8a48 100644 --- a/netbox/core/tables/__init__.py +++ b/netbox/core/tables/__init__.py @@ -1,2 +1,3 @@ +from .config import * from .data import * from .jobs import * diff --git a/netbox/core/tables/config.py b/netbox/core/tables/config.py new file mode 100644 index 000000000..9d4cb6393 --- /dev/null +++ b/netbox/core/tables/config.py @@ -0,0 +1,33 @@ +from django.utils.translation import gettext_lazy as _ + +from core.models import ConfigRevision +from netbox.tables import NetBoxTable, columns + +__all__ = ( + 'ConfigRevisionTable', +) + +REVISION_BUTTONS = """ +{% if not record.is_active %} + + + +{% endif %} +""" + + +class ConfigRevisionTable(NetBoxTable): + is_active = columns.BooleanColumn( + verbose_name=_('Is Active'), + ) + actions = columns.ActionsColumn( + actions=('delete',), + extra_buttons=REVISION_BUTTONS + ) + + class Meta(NetBoxTable.Meta): + model = ConfigRevision + fields = ( + 'pk', 'id', 'is_active', 'created', 'comment', + ) + default_columns = ('pk', 'id', 'is_active', 'created', 'comment') diff --git a/netbox/core/urls.py b/netbox/core/urls.py index f17a50c81..77c0d3194 100644 --- a/netbox/core/urls.py +++ b/netbox/core/urls.py @@ -25,6 +25,13 @@ urlpatterns = ( path('jobs//', views.JobView.as_view(), name='job'), path('jobs//delete/', views.JobDeleteView.as_view(), name='job_delete'), + # Config revisions + path('config-revisions/', views.ConfigRevisionListView.as_view(), name='configrevision_list'), + path('config-revisions/add/', views.ConfigRevisionEditView.as_view(), name='configrevision_add'), + path('config-revisions/delete/', views.ConfigRevisionBulkDeleteView.as_view(), name='configrevision_bulk_delete'), + path('config-revisions//restore/', views.ConfigRevisionRestoreView.as_view(), name='configrevision_restore'), + path('config-revisions//', include(get_model_urls('core', 'configrevision'))), + # Configuration path('config/', views.ConfigView.as_view(), name='config'), diff --git a/netbox/core/views.py b/netbox/core/views.py index d16fa4ece..61ef93642 100644 --- a/netbox/core/views.py +++ b/netbox/core/views.py @@ -1,12 +1,13 @@ from django.contrib import messages -from django.shortcuts import get_object_or_404, redirect +from django.http import HttpResponseForbidden +from django.shortcuts import get_object_or_404, redirect, render +from django.views.generic import View -from extras.models import ConfigRevision -from netbox.config import get_config +from netbox.config import get_config, PARAMS from netbox.views import generic from netbox.views.generic.base import BaseObjectView from utilities.utils import count_related -from utilities.views import register_model_view +from utilities.views import ContentTypePermissionRequiredMixin, register_model_view from . import filtersets, forms, tables from .models import * @@ -164,3 +165,67 @@ class ConfigView(generic.ObjectView): return ConfigRevision( data=get_config().defaults ) + + +class ConfigRevisionListView(generic.ObjectListView): + queryset = ConfigRevision.objects.all() + filterset = filtersets.ConfigRevisionFilterSet + filterset_form = forms.ConfigRevisionFilterForm + table = tables.ConfigRevisionTable + + +@register_model_view(ConfigRevision) +class ConfigRevisionView(generic.ObjectView): + queryset = ConfigRevision.objects.all() + + +class ConfigRevisionEditView(generic.ObjectEditView): + queryset = ConfigRevision.objects.all() + form = forms.ConfigRevisionForm + + +@register_model_view(ConfigRevision, 'delete') +class ConfigRevisionDeleteView(generic.ObjectDeleteView): + queryset = ConfigRevision.objects.all() + + +class ConfigRevisionBulkDeleteView(generic.BulkDeleteView): + queryset = ConfigRevision.objects.all() + filterset = filtersets.ConfigRevisionFilterSet + table = tables.ConfigRevisionTable + + +class ConfigRevisionRestoreView(ContentTypePermissionRequiredMixin, View): + + def get_required_permission(self): + return 'core.configrevision_edit' + + def get(self, request, pk): + candidate_config = get_object_or_404(ConfigRevision, pk=pk) + + # Get the current ConfigRevision + config_version = get_config().version + current_config = ConfigRevision.objects.filter(pk=config_version).first() + + params = [] + for param in PARAMS: + params.append(( + param.name, + current_config.data.get(param.name, None), + candidate_config.data.get(param.name, None) + )) + + return render(request, 'core/configrevision_restore.html', { + 'object': candidate_config, + 'params': params, + }) + + def post(self, request, pk): + if not request.user.has_perm('core.configrevision_edit'): + return HttpResponseForbidden() + + candidate_config = get_object_or_404(ConfigRevision, pk=pk) + candidate_config.activate() + messages.success(request, f"Restored configuration revision #{pk}") + + return redirect(candidate_config.get_absolute_url()) diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 32850bee2..d336394f9 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -17,7 +17,6 @@ from .models import * __all__ = ( 'BookmarkFilterSet', 'ConfigContextFilterSet', - 'ConfigRevisionFilterSet', 'ConfigTemplateFilterSet', 'ContentTypeFilterSet', 'CustomFieldChoiceSetFilterSet', @@ -625,27 +624,3 @@ class ContentTypeFilterSet(django_filters.FilterSet): Q(app_label__icontains=value) | Q(model__icontains=value) ) - - -# -# ConfigRevisions -# - -class ConfigRevisionFilterSet(BaseFilterSet): - q = django_filters.CharFilter( - method='search', - label=_('Search'), - ) - - class Meta: - model = ConfigRevision - fields = [ - 'id', - ] - - def search(self, queryset, name, value): - if not value.strip(): - return queryset - return queryset.filter( - Q(comment__icontains=value) - ) diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 28aefa685..b68845c2f 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -18,7 +18,6 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType __all__ = ( 'ConfigContextFilterForm', - 'ConfigRevisionFilterForm', 'ConfigTemplateFilterForm', 'CustomFieldChoiceSetFilterForm', 'CustomFieldFilterForm', @@ -499,9 +498,3 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm): api_url='/api/extras/content-types/', ) ) - - -class ConfigRevisionFilterForm(SavedFiltersMixin, FilterForm): - fieldsets = ( - (None, ('q', 'filter_id')), - ) diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 1a4d45f9a..9553a839a 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -1,7 +1,6 @@ import json from django import forms -from django.conf import settings from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ @@ -10,7 +9,6 @@ from core.models import ContentType from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from extras.choices import * from extras.models import * -from netbox.config import get_config, PARAMS from netbox.forms import NetBoxModelForm from tenancy.models import Tenant, TenantGroup from utilities.forms import BootstrapMixin, add_blank_choice @@ -21,11 +19,9 @@ from utilities.forms.fields import ( from utilities.forms.widgets import ChoicesWidget from virtualization.models import Cluster, ClusterGroup, ClusterType - __all__ = ( 'BookmarkForm', 'ConfigContextForm', - 'ConfigRevisionForm', 'ConfigTemplateForm', 'CustomFieldChoiceSetForm', 'CustomFieldForm', @@ -445,116 +441,3 @@ class JournalEntryForm(NetBoxModelForm): 'assigned_object_type': forms.HiddenInput, 'assigned_object_id': forms.HiddenInput, } - - -EMPTY_VALUES = ('', None, [], ()) - - -class ConfigFormMetaclass(forms.models.ModelFormMetaclass): - - def __new__(mcs, name, bases, attrs): - - # Emulate a declared field for each supported configuration parameter - param_fields = {} - for param in PARAMS: - field_kwargs = { - 'required': False, - 'label': param.label, - 'help_text': param.description, - } - field_kwargs.update(**param.field_kwargs) - param_fields[param.name] = param.field(**field_kwargs) - attrs.update(param_fields) - - return super().__new__(mcs, name, bases, attrs) - - -class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMetaclass): - """ - Form for creating a new ConfigRevision. - """ - - fieldsets = ( - (_('Rack Elevations'), ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH')), - (_('Power'), ('POWERFEED_DEFAULT_VOLTAGE', 'POWERFEED_DEFAULT_AMPERAGE', 'POWERFEED_DEFAULT_MAX_UTILIZATION')), - (_('IPAM'), ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4')), - (_('Security'), ('ALLOWED_URL_SCHEMES',)), - (_('Banners'), ('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM')), - (_('Pagination'), ('PAGINATE_COUNT', 'MAX_PAGE_SIZE')), - (_('Validation'), ('CUSTOM_VALIDATORS', 'PROTECTION_RULES')), - (_('User Preferences'), ('DEFAULT_USER_PREFERENCES',)), - (_('Miscellaneous'), ( - 'MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION', 'MAPS_URL', - )), - (_('Config Revision'), ('comment',)) - ) - - class Meta: - model = ConfigRevision - fields = '__all__' - widgets = { - 'BANNER_LOGIN': forms.Textarea(attrs={'class': 'font-monospace'}), - 'BANNER_MAINTENANCE': forms.Textarea(attrs={'class': 'font-monospace'}), - 'BANNER_TOP': forms.Textarea(attrs={'class': 'font-monospace'}), - 'BANNER_BOTTOM': forms.Textarea(attrs={'class': 'font-monospace'}), - 'CUSTOM_VALIDATORS': forms.Textarea(attrs={'class': 'font-monospace'}), - 'PROTECTION_RULES': forms.Textarea(attrs={'class': 'font-monospace'}), - 'comment': forms.Textarea(), - } - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Append current parameter values to form field help texts and check for static configurations - config = get_config() - for param in PARAMS: - value = getattr(config, param.name) - - # Set the field's initial value, if it can be serialized. (This may not be the case e.g. for - # CUSTOM_VALIDATORS, which may reference Python objects.) - try: - json.dumps(value) - if type(value) in (tuple, list): - self.fields[param.name].initial = ', '.join(value) - else: - self.fields[param.name].initial = value - except TypeError: - pass - - # Check whether this parameter is statically configured (e.g. in configuration.py) - if hasattr(settings, param.name): - self.fields[param.name].disabled = True - self.fields[param.name].help_text = _( - 'This parameter has been defined statically and cannot be modified.' - ) - continue - - # Set the field's help text - help_text = self.fields[param.name].help_text - if help_text: - help_text += '
' # Line break - help_text += _('Current value: {value}').format(value=value or '—') - if value == param.default: - help_text += _(' (default)') - self.fields[param.name].help_text = help_text - - def save(self, commit=True): - instance = super().save(commit=False) - - # Populate JSON data on the instance - instance.data = self.render_json() - - if commit: - instance.save() - - return instance - - def render_json(self): - json = {} - - # Iterate through each field and populate non-empty values - for field_name in self.declared_fields: - if field_name in self.cleaned_data and self.cleaned_data[field_name] not in EMPTY_VALUES: - json[field_name] = self.cleaned_data[field_name] - - return json diff --git a/netbox/extras/migrations/0101_move_configrevision.py b/netbox/extras/migrations/0101_move_configrevision.py new file mode 100644 index 000000000..730e7a096 --- /dev/null +++ b/netbox/extras/migrations/0101_move_configrevision.py @@ -0,0 +1,39 @@ +from django.db import migrations + + +def update_content_type(apps, schema_editor): + ContentType = apps.get_model('contenttypes', 'ContentType') + + # Delete the new ContentType effected by the introduction of core.ConfigRevision + ContentType.objects.filter(app_label='core', model='configrevision').delete() + + # Update the app label of the original ContentType for extras.ConfigRevision to ensure any foreign key + # references are preserved + ContentType.objects.filter(app_label='extras', model='configrevision').update(app_label='core') + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0100_customfield_ui_attrs'), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + state_operations=[ + migrations.DeleteModel( + name='ConfigRevision', + ), + ], + database_operations=[ + migrations.AlterModelTable( + name='ConfigRevision', + table='core_configrevision', + ), + ], + ), + migrations.RunPython( + code=update_content_type, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 67b455ab4..d0a2e4b61 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -3,14 +3,13 @@ import urllib.parse from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey -from django.core.cache import cache from django.core.validators import ValidationError from django.db import models from django.http import HttpResponse from django.urls import reverse from django.utils import timezone from django.utils.formats import date_format -from django.utils.translation import gettext, gettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from rest_framework.utils.encoders import JSONEncoder from core.models import ContentType @@ -28,7 +27,6 @@ from utilities.utils import clean_html, dict_to_querydict, render_jinja2 __all__ = ( 'Bookmark', - 'ConfigRevision', 'CustomLink', 'ExportTemplate', 'ImageAttachment', @@ -710,59 +708,3 @@ class Bookmark(models.Model): raise ValidationError( _("Bookmarks cannot be assigned to this object type ({type}).").format(type=self.object_type) ) - - -class ConfigRevision(models.Model): - """ - An atomic revision of NetBox's configuration. - """ - created = models.DateTimeField( - verbose_name=_('created'), - auto_now_add=True - ) - comment = models.CharField( - verbose_name=_('comment'), - max_length=200, - blank=True - ) - data = models.JSONField( - blank=True, - null=True, - verbose_name=_('configuration data') - ) - - objects = RestrictedQuerySet.as_manager() - - class Meta: - ordering = ['-created'] - verbose_name = _('config revision') - verbose_name_plural = _('config revisions') - - def __str__(self): - if not self.pk: - return gettext('Default configuration') - if self.is_active: - return gettext('Current configuration') - return gettext('Config revision #{id}').format(id=self.pk) - - def __getattr__(self, item): - if item in self.data: - return self.data[item] - return super().__getattribute__(item) - - def get_absolute_url(self): - if not self.pk: - return reverse('core:config') # Default config view - return reverse('extras:configrevision', args=[self.pk]) - - def activate(self): - """ - Cache the configuration data. - """ - cache.set('config', self.data, None) - cache.set('config_version', self.pk, None) - activate.alters_data = True - - @property - def is_active(self): - return cache.get('config_version') == self.pk diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index 8bdaf523c..e1d424960 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -14,7 +14,7 @@ from netbox.context import current_request, webhooks_queue from netbox.signals import post_clean from utilities.exceptions import AbortRequest from .choices import ObjectChangeActionChoices -from .models import ConfigRevision, CustomField, ObjectChange, TaggedItem +from .models import CustomField, ObjectChange, TaggedItem from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook # @@ -219,18 +219,6 @@ def run_delete_validators(sender, instance, **kwargs): ) -# -# Dynamic configuration -# - -@receiver(post_save, sender=ConfigRevision) -def update_config(sender, instance, **kwargs): - """ - Update the cached NetBox configuration when a new ConfigRevision is created. - """ - instance.activate() - - # # Tags # diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 54194c00f..b78ab0c94 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -11,7 +11,6 @@ from .template_code import * __all__ = ( 'BookmarkTable', 'ConfigContextTable', - 'ConfigRevisionTable', 'ConfigTemplateTable', 'CustomFieldChoiceSetTable', 'CustomFieldTable', @@ -34,31 +33,6 @@ IMAGEATTACHMENT_IMAGE = ''' {% endif %} ''' -REVISION_BUTTONS = """ -{% if not record.is_active %} - - - -{% endif %} -""" - - -class ConfigRevisionTable(NetBoxTable): - is_active = columns.BooleanColumn( - verbose_name=_('Is Active'), - ) - actions = columns.ActionsColumn( - actions=('delete',), - extra_buttons=REVISION_BUTTONS - ) - - class Meta(NetBoxTable.Meta): - model = ConfigRevision - fields = ( - 'pk', 'id', 'is_active', 'created', 'comment', - ) - default_columns = ('pk', 'id', 'is_active', 'created', 'comment') - class CustomFieldTable(NetBoxTable): name = tables.Column( diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index fd95186e4..bcab007e7 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -98,13 +98,6 @@ urlpatterns = [ path('journal-entries/import/', views.JournalEntryBulkImportView.as_view(), name='journalentry_import'), path('journal-entries//', include(get_model_urls('extras', 'journalentry'))), - # Config revisions - path('config-revisions/', views.ConfigRevisionListView.as_view(), name='configrevision_list'), - path('config-revisions/add/', views.ConfigRevisionEditView.as_view(), name='configrevision_add'), - path('config-revisions/delete/', views.ConfigRevisionBulkDeleteView.as_view(), name='configrevision_bulk_delete'), - path('config-revisions//restore/', views.ConfigRevisionRestoreView.as_view(), name='configrevision_restore'), - path('config-revisions//', include(get_model_urls('extras', 'configrevision'))), - # Change logging path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'), path('changelog//', include(get_model_urls('extras', 'objectchange'))), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 0e8e3b0ea..b62165e1a 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -15,7 +15,6 @@ from core.models import Job from core.tables import JobTable from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm from extras.dashboard.utils import get_widget_class -from netbox.config import get_config, PARAMS from netbox.constants import DEFAULT_ACTION_PERMISSIONS from netbox.views import generic from utilities.forms import ConfirmationForm, get_field_value @@ -1316,74 +1315,6 @@ class ScriptResultView(ContentTypePermissionRequiredMixin, View): }) -# -# Config Revisions -# - -class ConfigRevisionListView(generic.ObjectListView): - queryset = ConfigRevision.objects.all() - filterset = filtersets.ConfigRevisionFilterSet - filterset_form = forms.ConfigRevisionFilterForm - table = tables.ConfigRevisionTable - - -@register_model_view(ConfigRevision) -class ConfigRevisionView(generic.ObjectView): - queryset = ConfigRevision.objects.all() - - -class ConfigRevisionEditView(generic.ObjectEditView): - queryset = ConfigRevision.objects.all() - form = forms.ConfigRevisionForm - - -@register_model_view(ConfigRevision, 'delete') -class ConfigRevisionDeleteView(generic.ObjectDeleteView): - queryset = ConfigRevision.objects.all() - - -class ConfigRevisionBulkDeleteView(generic.BulkDeleteView): - queryset = ConfigRevision.objects.all() - filterset = filtersets.ConfigRevisionFilterSet - table = tables.ConfigRevisionTable - - -class ConfigRevisionRestoreView(ContentTypePermissionRequiredMixin, View): - - def get_required_permission(self): - return 'extras.configrevision_edit' - - def get(self, request, pk): - candidate_config = get_object_or_404(ConfigRevision, pk=pk) - - # Get the current ConfigRevision - config_version = get_config().version - current_config = ConfigRevision.objects.filter(pk=config_version).first() - - params = [] - for param in PARAMS: - params.append(( - param.name, - current_config.data.get(param.name, None), - candidate_config.data.get(param.name, None) - )) - - return render(request, 'extras/configrevision_restore.html', { - 'object': candidate_config, - 'params': params, - }) - - def post(self, request, pk): - if not request.user.has_perm('extras.configrevision_edit'): - return HttpResponseForbidden() - - candidate_config = get_object_or_404(ConfigRevision, pk=pk) - candidate_config.activate() - messages.success(request, f"Restored configuration revision #{pk}") - - return redirect(candidate_config.get_absolute_url()) - - # # Markdown # diff --git a/netbox/netbox/config/__init__.py b/netbox/netbox/config/__init__.py index a9a93636c..c536ceadb 100644 --- a/netbox/netbox/config/__init__.py +++ b/netbox/netbox/config/__init__.py @@ -74,7 +74,7 @@ class Config: def _populate_from_db(self): """Cache data from latest ConfigRevision, then populate from cache""" - from extras.models import ConfigRevision + from core.models import ConfigRevision try: revision = ConfigRevision.objects.last() diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 43cf3f869..7ad317324 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -424,13 +424,13 @@ ADMIN_MENU = Menu( MenuItem( link='core:config', link_text=_('Current Config'), - permissions=['extras.view_configrevision'], + permissions=['core.view_configrevision'], staff_only=True ), MenuItem( - link='extras:configrevision_list', + link='core:configrevision_list', link_text=_('Config Revisions'), - permissions=['extras.view_configrevision'], + permissions=['core.view_configrevision'], staff_only=True ), ), diff --git a/netbox/netbox/tests/test_config.py b/netbox/netbox/tests/test_config.py index db401cf0c..f8c892363 100644 --- a/netbox/netbox/tests/test_config.py +++ b/netbox/netbox/tests/test_config.py @@ -2,7 +2,7 @@ from django.conf import settings from django.core.cache import cache from django.test import override_settings, TestCase -from extras.models import ConfigRevision +from core.models import ConfigRevision from netbox.config import clear_config, get_config diff --git a/netbox/templates/extras/configrevision.html b/netbox/templates/core/configrevision.html similarity index 96% rename from netbox/templates/extras/configrevision.html rename to netbox/templates/core/configrevision.html index a880865c3..6481127fa 100644 --- a/netbox/templates/extras/configrevision.html +++ b/netbox/templates/core/configrevision.html @@ -14,11 +14,11 @@
{% plugin_buttons object %} - {% if not object.pk or object.is_active and perms.extras.add_configrevision %} - {% url 'extras:configrevision_add' as edit_url %} + {% if not object.pk or object.is_active and perms.core.add_configrevision %} + {% url 'core:configrevision_add' as edit_url %} {% include "buttons/edit.html" with url=edit_url %} {% endif %} - {% if object.pk and not object.is_active and perms.extras.delete_configrevision %} + {% if object.pk and not object.is_active and perms.core.delete_configrevision %} {% delete_button object %} {% endif %}
diff --git a/netbox/templates/extras/configrevision_restore.html b/netbox/templates/core/configrevision_restore.html similarity index 85% rename from netbox/templates/extras/configrevision_restore.html rename to netbox/templates/core/configrevision_restore.html index 134a0b547..ad6fb1bd9 100644 --- a/netbox/templates/extras/configrevision_restore.html +++ b/netbox/templates/core/configrevision_restore.html @@ -18,8 +18,8 @@ @@ -77,7 +77,7 @@
- {% trans "Cancel" %} + {% trans "Cancel" %}
From 6678880db5332644dfce57c4bc605958b5bb8a43 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 27 Nov 2023 16:17:15 -0500 Subject: [PATCH 03/22] Closes #9816: VPN tunnel support (#14276) - Introduces a new `vpn` app with the following models: - Tunnel - TunnelTermination - IKEProposal - IKEPolicy - IPSecProposal - IPSecPolicy - IPSecProfile --- 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 + netbox/core/management/commands/nbshell.py | 2 +- netbox/dcim/models/device_components.py | 10 + netbox/dcim/tables/devices.py | 13 +- netbox/dcim/tables/template_code.py | 10 + netbox/netbox/api/views.py | 1 + netbox/netbox/graphql/schema.py | 2 + netbox/netbox/navigation/menu.py | 25 +- netbox/netbox/settings.py | 1 + netbox/netbox/urls.py | 2 + netbox/templates/vpn/ikepolicy.html | 67 ++ netbox/templates/vpn/ikeproposal.html | 63 ++ netbox/templates/vpn/ipsecpolicy.html | 55 ++ netbox/templates/vpn/ipsecprofile.html | 112 ++++ netbox/templates/vpn/ipsecproposal.html | 59 ++ netbox/templates/vpn/tunnel.html | 85 +++ netbox/templates/vpn/tunneltermination.html | 62 ++ .../virtualization/models/virtualmachines.py | 6 + .../virtualization/tables/virtualmachines.py | 5 +- netbox/vpn/__init__.py | 0 netbox/vpn/admin.py | 3 + netbox/vpn/api/__init__.py | 0 netbox/vpn/api/nested_serializers.py | 84 +++ netbox/vpn/api/serializers.py | 193 ++++++ netbox/vpn/api/urls.py | 15 + netbox/vpn/api/views.py | 74 +++ netbox/vpn/apps.py | 9 + netbox/vpn/choices.py | 201 ++++++ netbox/vpn/filtersets.py | 241 +++++++ netbox/vpn/forms/__init__.py | 4 + netbox/vpn/forms/bulk_edit.py | 243 +++++++ netbox/vpn/forms/bulk_import.py | 230 +++++++ netbox/vpn/forms/filtersets.py | 182 ++++++ netbox/vpn/forms/model_forms.py | 357 +++++++++++ netbox/vpn/graphql/__init__.py | 0 netbox/vpn/graphql/schema.py | 51 ++ netbox/vpn/graphql/types.py | 69 ++ netbox/vpn/migrations/0001_initial.py | 186 ++++++ netbox/vpn/migrations/__init__.py | 0 netbox/vpn/models/__init__.py | 2 + netbox/vpn/models/crypto.py | 254 ++++++++ netbox/vpn/models/tunnels.py | 146 +++++ netbox/vpn/search.py | 65 ++ netbox/vpn/tables.py | 254 ++++++++ netbox/vpn/tests/__init__.py | 0 netbox/vpn/tests/test_api.py | 473 ++++++++++++++ netbox/vpn/tests/test_filtersets.py | 592 ++++++++++++++++++ netbox/vpn/tests/test_views.py | 508 +++++++++++++++ netbox/vpn/urls.py | 65 ++ netbox/vpn/views.py | 334 ++++++++++ 58 files changed, 5656 insertions(+), 10 deletions(-) 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 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/ipsecprofile.html create mode 100644 netbox/templates/vpn/ipsecproposal.html create mode 100644 netbox/templates/vpn/tunnel.html create mode 100644 netbox/templates/vpn/tunneltermination.html 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/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/__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/tests/__init__.py create mode 100644 netbox/vpn/tests/test_api.py create mode 100644 netbox/vpn/tests/test_filtersets.py create mode 100644 netbox/vpn/tests/test_views.py create mode 100644 netbox/vpn/urls.py create mode 100644 netbox/vpn/views.py diff --git a/docs/features/vpn-tunnels.md b/docs/features/vpn-tunnels.md new file mode 100644 index 000000000..ae6df70c8 --- /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, and 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 000000000..7b739072b --- /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 000000000..dd8d75330 --- /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 000000000..3283d3b23 --- /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 000000000..1ad1ce7d5 --- /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 000000000..d061b1535 --- /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 000000000..ebe004da1 --- /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 000000000..8400eaa86 --- /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. + +### Termination + +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 3e61f922a..f927bf386 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' diff --git a/netbox/core/management/commands/nbshell.py b/netbox/core/management/commands/nbshell.py index 674a878c7..fd86627d2 100644 --- a/netbox/core/management/commands/nbshell.py +++ b/netbox/core/management/commands/nbshell.py @@ -9,7 +9,7 @@ from django.contrib.auth import get_user_model 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/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index c24ed4d86..705af7637 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -566,6 +566,10 @@ class BaseInterface(models.Model): 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() @@ -719,6 +723,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/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index b72c37daa..60e203697 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 InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi '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 DeviceInterfaceTable(InterfaceTable): '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/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index e0f38afef..a24f9ea6d 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -359,6 +359,16 @@ INTERFACE_BUTTONS = """ {% 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/netbox/api/views.py b/netbox/netbox/api/views.py index 4e71ca193..cfbe82f14 100644 --- a/netbox/netbox/api/views.py +++ b/netbox/netbox/api/views.py @@ -39,6 +39,7 @@ class APIRootView(APIView): '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/netbox/graphql/schema.py b/netbox/netbox/graphql/schema.py index 7224f3c38..021d6d902 100644 --- a/netbox/netbox/graphql/schema.py +++ b/netbox/netbox/graphql/schema.py @@ -9,6 +9,7 @@ from netbox.registry import registry 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 diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 7ad317324..e99b84b10 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -195,17 +195,34 @@ IPAM_MENU = Menu( ), ) -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', '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')), + ), + ), ), ) @@ -444,7 +461,7 @@ MENUS = [ 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 465389a11..ce8ab5876 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -379,6 +379,7 @@ INSTALLED_APPS = [ '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 6955426a8..984358911 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -33,6 +33,7 @@ _patterns = [ 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 @@ _patterns = [ 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/templates/vpn/ikepolicy.html b/netbox/templates/vpn/ikepolicy.html new file mode 100644 index 000000000..559ba6d17 --- /dev/null +++ b/netbox/templates/vpn/ikepolicy.html @@ -0,0 +1,67 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load i18n %} + +{% block content %} +
+
+
+
{% trans "IKE Policy" %}
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "IKE Version" %}{{ object.get_version_display }}
{% trans "Mode" %}{{ object.get_mode_display }}
{% trans "Pre-Shared Key" %} + {{ object.preshared_key|placeholder }} + {% if object.preshared_key %} + + {% endif %} +
{% trans "IPSec Profiles" %} + {{ object.ipsec_profiles.count }} +
+
+
+ {% plugin_left_page object %} +
+
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% plugin_right_page object %} +
+
+
+
+
+
{% trans "Proposals" %}
+
+
+ {% 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 000000000..33cf60c81 --- /dev/null +++ b/netbox/templates/vpn/ikeproposal.html @@ -0,0 +1,63 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load i18n %} + +{% block content %} +
+
+
+
{% trans "IKE Proposal" %}
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
{% 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 "IKE Policies" %} + {{ object.ike_policies.count }} +
+
+
+ {% 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 000000000..4960d9dd3 --- /dev/null +++ b/netbox/templates/vpn/ipsecpolicy.html @@ -0,0 +1,55 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load i18n %} + +{% block content %} +
+
+
+
{% trans "IPSec Policy" %}
+
+ + + + + + + + + + + + + + + + + +
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "PFS group" %}{{ object.get_pfs_group_display|placeholder }}
{% trans "IPSec Profiles" %} + {{ object.ipsec_profiles.count }} +
+
+
+ {% plugin_left_page object %} +
+
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% plugin_right_page object %} +
+
+
+
+
+
+
{% trans "Proposals" %}
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/vpn/ipsecprofile.html b/netbox/templates/vpn/ipsecprofile.html new file mode 100644 index 000000000..08fa3074e --- /dev/null +++ b/netbox/templates/vpn/ipsecprofile.html @@ -0,0 +1,112 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load i18n %} + +{% block content %} +
+
+
+
{% trans "IPSec Profile" %}
+
+ + + + + + + + + + + + + +
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
{% 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 "Name" %}{{ object.ike_policy|linkify }}
{% 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 "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 }}
+
+
+ {% 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 000000000..7425eef43 --- /dev/null +++ b/netbox/templates/vpn/ipsecproposal.html @@ -0,0 +1,59 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load i18n %} + +{% block content %} +
+
+
+
{% trans "IPSec Proposal" %}
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
{% 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 "IPSec Policies" %} + {{ object.ipsec_policies.count }} +
+
+
+ {% 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/tunnel.html b/netbox/templates/vpn/tunnel.html new file mode 100644 index 000000000..544ffadae --- /dev/null +++ b/netbox/templates/vpn/tunnel.html @@ -0,0 +1,85 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load i18n %} + +{% block extra_controls %} + {% if perms.vpn.add_tunneltermination %} + + {% trans "Add Termination" %} + + {% endif %} +{% endblock %} + +{% block content %} +
+
+
+
{% trans "Tunnel" %}
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Name" %}{{ object.name }}
{% trans "Status" %}{% badge object.get_status_display bg_color=object.get_status_color %}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Encapsulation" %}{{ object.get_encapsulation_display }}
{% trans "IPSec profile" %}{{ object.ipsec_profile|linkify|placeholder }}
{% trans "Tunnel ID" %}{{ object.tunnel_id|placeholder }}
{% trans "Tenant" %} + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / + {% endif %} + {{ object.tenant|linkify|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/templates/vpn/tunneltermination.html b/netbox/templates/vpn/tunneltermination.html new file mode 100644 index 000000000..6f4e83ce0 --- /dev/null +++ b/netbox/templates/vpn/tunneltermination.html @@ -0,0 +1,62 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load i18n %} + +{% block content %} +
+
+
+
{% trans "Tunnel Termination" %}
+
+ + + + + + + + + + + + + + + + + + + + + +
{% trans "Tunnel" %}{{ object.tunnel|linkify }}
{% trans "Role" %}{% badge object.get_role_display bg_color=object.get_role_color %}
+ {% if object.termination.device %} + {% trans "Device" %} + {% elif object.termination.virtual_machine %} + {% trans "Virtual Machine" %} + {% endif %} + {{ object.termination.parent_object|linkify }}
{% trans "Interface" %}{{ object.termination|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 %} +
+
+
+
+
+
{% trans "Peer Terminations" %}
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/virtualization/models/virtualmachines.py b/netbox/virtualization/models/virtualmachines.py index 705419186..2126f2541 100644 --- a/netbox/virtualization/models/virtualmachines.py +++ b/netbox/virtualization/models/virtualmachines.py @@ -351,6 +351,12 @@ class VMInterface(ComponentModel, 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/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py index 88627462a..1eeb06ea8 100644 --- a/netbox/virtualization/tables/virtualmachines.py +++ b/netbox/virtualization/tables/virtualmachines.py @@ -131,7 +131,8 @@ class VMInterfaceTable(BaseInterfaceTable): model = VMInterface fields = ( 'pk', 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags', - 'vrf', 'l2vpn', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated', + 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', + 'last_updated', ) default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description') @@ -154,7 +155,7 @@ class VirtualMachineVMInterfaceTable(VMInterfaceTable): model = VMInterface fields = ( 'pk', 'id', 'name', 'enabled', 'parent', 'bridge', 'mac_address', 'mtu', 'mode', 'description', 'tags', - 'vrf', 'l2vpn', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'actions', + 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'actions', ) default_columns = ('pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'ip_addresses') row_attrs = { diff --git a/netbox/vpn/__init__.py b/netbox/vpn/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/vpn/admin.py b/netbox/vpn/admin.py new file mode 100644 index 000000000..8c38f3f3d --- /dev/null +++ b/netbox/vpn/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/netbox/vpn/api/__init__.py b/netbox/vpn/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/vpn/api/nested_serializers.py b/netbox/vpn/api/nested_serializers.py new file mode 100644 index 000000000..c9c92d308 --- /dev/null +++ b/netbox/vpn/api/nested_serializers.py @@ -0,0 +1,84 @@ +from rest_framework import serializers + +from netbox.api.serializers import WritableNestedSerializer +from vpn import models + +__all__ = ( + 'NestedIKEPolicySerializer', + 'NestedIKEProposalSerializer', + 'NestedIPSecPolicySerializer', + 'NestedIPSecProfileSerializer', + 'NestedIPSecProposalSerializer', + '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 NestedIKEProposalSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='vpn-api:ikeproposal-detail' + ) + + class Meta: + model = models.IKEProposal + fields = ('id', 'url', 'display', 'name') + + +class NestedIKEPolicySerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='vpn-api:ikepolicy-detail' + ) + + class Meta: + model = models.IKEPolicy + fields = ('id', 'url', 'display', 'name') + + +class NestedIPSecProposalSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='vpn-api:ipsecproposal-detail' + ) + + class Meta: + model = models.IPSecProposal + fields = ('id', 'url', 'display', 'name') + + +class NestedIPSecPolicySerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='vpn-api:ipsecpolicy-detail' + ) + + class Meta: + model = models.IPSecPolicy + fields = ('id', 'url', 'display', 'name') + + +class NestedIPSecProfileSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='vpn-api:ipsecprofile-detail' + ) + + class Meta: + model = models.IPSecProfile + fields = ('id', 'url', 'display', 'name') diff --git a/netbox/vpn/api/serializers.py b/netbox/vpn/api/serializers.py new file mode 100644 index 000000000..1a517fe59 --- /dev/null +++ b/netbox/vpn/api/serializers.py @@ -0,0 +1,193 @@ +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, SerializedPKRelatedField +from netbox.api.serializers import NetBoxModelSerializer +from netbox.constants import NESTED_SERIALIZER_PREFIX +from tenancy.api.nested_serializers import NestedTenantSerializer +from utilities.api import get_serializer_for_model +from vpn.choices import * +from vpn.models import * +from .nested_serializers import * + +__all__ = ( + 'IKEPolicySerializer', + 'IKEProposalSerializer', + 'IPSecPolicySerializer', + 'IPSecProfileSerializer', + 'IPSecProposalSerializer', + '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', 'tunnel_id', + 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + ) + + +class TunnelTerminationSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='vpn-api:tunneltermination-detail' + ) + tunnel = NestedTunnelSerializer() + role = ChoiceField( + choices=TunnelTerminationRoleChoices + ) + termination_type = ContentTypeField( + queryset=ContentType.objects.all() + ) + termination = serializers.SerializerMethodField( + read_only=True + ) + outside_ip = NestedIPAddressSerializer( + required=False, + allow_null=True + ) + + class Meta: + model = TunnelTermination + fields = ( + 'id', 'url', 'display', 'tunnel', 'role', 'termination_type', 'termination_id', 'termination', 'outside_ip', + 'tags', 'custom_fields', 'created', 'last_updated', + ) + + @extend_schema_field(serializers.JSONField(allow_null=True)) + def get_termination(self, obj): + serializer = get_serializer_for_model(obj.termination, prefix=NESTED_SERIALIZER_PREFIX) + context = {'request': self.context['request']} + return serializer(obj.termination, context=context).data + + +class IKEProposalSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='vpn-api:ikeproposal-detail' + ) + authentication_method = ChoiceField( + choices=AuthenticationMethodChoices + ) + encryption_algorithm = ChoiceField( + choices=EncryptionAlgorithmChoices + ) + authentication_algorithm = ChoiceField( + choices=AuthenticationAlgorithmChoices + ) + group = ChoiceField( + choices=DHGroupChoices + ) + + class Meta: + model = IKEProposal + fields = ( + 'id', 'url', 'display', 'name', 'description', 'authentication_method', 'encryption_algorithm', + 'authentication_algorithm', 'group', 'sa_lifetime', 'tags', 'custom_fields', 'created', 'last_updated', + ) + + +class IKEPolicySerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='vpn-api:ikepolicy-detail' + ) + version = ChoiceField( + choices=IKEVersionChoices + ) + mode = ChoiceField( + choices=IKEModeChoices + ) + proposals = SerializedPKRelatedField( + queryset=IKEProposal.objects.all(), + serializer=NestedIKEProposalSerializer, + required=False, + many=True + ) + + class Meta: + model = IKEPolicy + fields = ( + 'id', 'url', 'display', 'name', 'description', 'version', 'mode', 'proposals', 'preshared_key', 'tags', + 'custom_fields', 'created', 'last_updated', + ) + + +class IPSecProposalSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='vpn-api:ipsecproposal-detail' + ) + encryption_algorithm = ChoiceField( + choices=EncryptionAlgorithmChoices + ) + authentication_algorithm = ChoiceField( + choices=AuthenticationAlgorithmChoices + ) + + class Meta: + model = IPSecProposal + fields = ( + 'id', 'url', 'display', 'name', 'description', 'encryption_algorithm', 'authentication_algorithm', + 'sa_lifetime_seconds', 'sa_lifetime_data', 'tags', 'custom_fields', 'created', 'last_updated', + ) + + +class IPSecPolicySerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='vpn-api:ipsecpolicy-detail' + ) + proposals = SerializedPKRelatedField( + queryset=IPSecProposal.objects.all(), + serializer=NestedIPSecProposalSerializer, + required=False, + many=True + ) + pfs_group = ChoiceField( + choices=DHGroupChoices, + required=False + ) + + class Meta: + model = IPSecPolicy + fields = ( + 'id', 'url', 'display', 'name', 'description', 'proposals', 'pfs_group', 'tags', 'custom_fields', 'created', + 'last_updated', + ) + + +class IPSecProfileSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='vpn-api:ipsecprofile-detail' + ) + mode = ChoiceField( + choices=IPSecModeChoices + ) + ike_policy = NestedIKEPolicySerializer() + ipsec_policy = NestedIPSecPolicySerializer() + + class Meta: + model = IPSecProfile + fields = ( + 'id', 'url', 'display', 'name', 'description', 'mode', 'ike_policy', 'ipsec_policy', 'comments', 'tags', + 'custom_fields', 'created', 'last_updated', + ) diff --git a/netbox/vpn/api/urls.py b/netbox/vpn/api/urls.py new file mode 100644 index 000000000..f646174d5 --- /dev/null +++ b/netbox/vpn/api/urls.py @@ -0,0 +1,15 @@ +from netbox.api.routers import NetBoxRouter +from . import views + +router = NetBoxRouter() +router.APIRootView = views.VPNRootView +router.register('ike-policies', views.IKEPolicyViewSet) +router.register('ike-proposals', views.IKEProposalViewSet) +router.register('ipsec-policies', views.IPSecPolicyViewSet) +router.register('ipsec-proposals', views.IPSecProposalViewSet) +router.register('ipsec-profiles', views.IPSecProfileViewSet) +router.register('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 000000000..c0ccab7ab --- /dev/null +++ b/netbox/vpn/api/views.py @@ -0,0 +1,74 @@ +from rest_framework.routers import APIRootView + +from netbox.api.viewsets import NetBoxModelViewSet +from utilities.utils import count_related +from vpn import filtersets +from vpn.models import * +from . import serializers + +__all__ = ( + 'IKEPolicyViewSet', + 'IKEProposalViewSet', + 'IPSecPolicyViewSet', + 'IPSecProfileViewSet', + 'IPSecProposalViewSet', + '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 = TunnelTermination.objects.prefetch_related('tunnel') + serializer_class = serializers.TunnelTerminationSerializer + filterset_class = filtersets.TunnelTerminationFilterSet + + +class IKEProposalViewSet(NetBoxModelViewSet): + queryset = IKEProposal.objects.all() + serializer_class = serializers.IKEProposalSerializer + filterset_class = filtersets.IKEProposalFilterSet + + +class IKEPolicyViewSet(NetBoxModelViewSet): + queryset = IKEPolicy.objects.all() + serializer_class = serializers.IKEPolicySerializer + filterset_class = filtersets.IKEPolicyFilterSet + + +class IPSecProposalViewSet(NetBoxModelViewSet): + queryset = IPSecProposal.objects.all() + serializer_class = serializers.IPSecProposalSerializer + filterset_class = filtersets.IPSecProposalFilterSet + + +class IPSecPolicyViewSet(NetBoxModelViewSet): + queryset = IPSecPolicy.objects.all() + serializer_class = serializers.IPSecPolicySerializer + filterset_class = filtersets.IPSecPolicyFilterSet + + +class IPSecProfileViewSet(NetBoxModelViewSet): + queryset = IPSecProfile.objects.all() + serializer_class = serializers.IPSecProfileSerializer + filterset_class = filtersets.IPSecProfileFilterSet diff --git a/netbox/vpn/apps.py b/netbox/vpn/apps.py new file mode 100644 index 000000000..2254befd3 --- /dev/null +++ b/netbox/vpn/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class VPNConfig(AppConfig): + name = 'vpn' + verbose_name = 'VPN' + + def ready(self): + from . import search diff --git a/netbox/vpn/choices.py b/netbox/vpn/choices.py new file mode 100644 index 000000000..a932c5055 --- /dev/null +++ b/netbox/vpn/choices.py @@ -0,0 +1,201 @@ +from django.utils.translation import gettext_lazy as _ + +from utilities.choices import ChoiceSet + + +# +# Tunnels +# + +class TunnelStatusChoices(ChoiceSet): + key = 'Tunnel.status' + + STATUS_PLANNED = 'planned' + STATUS_ACTIVE = 'active' + STATUS_DISABLED = 'disabled' + + CHOICES = [ + (STATUS_PLANNED, _('Planned'), 'cyan'), + (STATUS_ACTIVE, _('Active'), 'green'), + (STATUS_DISABLED, _('Disabled'), 'red'), + ] + + +class TunnelEncapsulationChoices(ChoiceSet): + ENCAP_GRE = 'gre' + ENCAP_IP_IP = 'ip-ip' + ENCAP_IPSEC_TRANSPORT = 'ipsec-transport' + ENCAP_IPSEC_TUNNEL = 'ipsec-tunnel' + + CHOICES = [ + (ENCAP_IPSEC_TRANSPORT, _('IPsec - Transport')), + (ENCAP_IPSEC_TUNNEL, _('IPsec - Tunnel')), + (ENCAP_IP_IP, _('IP-in-IP')), + (ENCAP_GRE, _('GRE')), + ] + + +class TunnelTerminationTypeChoices(ChoiceSet): + # For TunnelCreateForm + TYPE_DEVICE = 'dcim.device' + TYPE_VIRUTALMACHINE = 'virtualization.virtualmachine' + + CHOICES = ( + (TYPE_DEVICE, _('Device')), + (TYPE_VIRUTALMACHINE, _('Virtual Machine')), + ) + + +class TunnelTerminationRoleChoices(ChoiceSet): + ROLE_PEER = 'peer' + ROLE_HUB = 'hub' + ROLE_SPOKE = 'spoke' + + CHOICES = [ + (ROLE_PEER, _('Peer'), 'green'), + (ROLE_HUB, _('Hub'), 'blue'), + (ROLE_SPOKE, _('Spoke'), 'orange'), + ] + + +# +# Crypto +# + +class IKEVersionChoices(ChoiceSet): + VERSION_1 = 1 + VERSION_2 = 2 + + CHOICES = ( + (VERSION_1, 'IKEv1'), + (VERSION_2, 'IKEv2'), + ) + + +class IKEModeChoices(ChoiceSet): + AGGRESSIVE = 'aggressive' + MAIN = 'main' + + CHOICES = ( + (AGGRESSIVE, _('Aggressive')), + (MAIN, _('Main')), + ) + + +class AuthenticationMethodChoices(ChoiceSet): + PRESHARED_KEYS = 'preshared-keys' + CERTIFICATES = 'certificates' + RSA_SIGNATURES = 'rsa-signatures' + DSA_SIGNATURES = 'dsa-signatures' + + CHOICES = ( + (PRESHARED_KEYS, _('Pre-shared keys')), + (CERTIFICATES, _('Certificates')), + (RSA_SIGNATURES, _('RSA signatures')), + (DSA_SIGNATURES, _('DSA signatures')), + ) + + +class IPSecModeChoices(ChoiceSet): + ESP = 'esp' + AH = 'ah' + + CHOICES = ( + (ESP, 'ESP'), + (AH, 'AH'), + ) + + +class EncryptionAlgorithmChoices(ChoiceSet): + ENCRYPTION_AES128_CBC = 'aes-128-cbc' + ENCRYPTION_AES128_GCM = 'aes-128-gcm' + ENCRYPTION_AES192_CBC = 'aes-192-cbc' + ENCRYPTION_AES192_GCM = 'aes-192-gcm' + ENCRYPTION_AES256_CBC = 'aes-256-cbc' + ENCRYPTION_AES256_GCM = 'aes-256-gcm' + ENCRYPTION_3DES = '3des-cbc' + ENCRYPTION_DES = 'des-cbc' + + CHOICES = ( + (ENCRYPTION_AES128_CBC, '128-bit AES (CBC)'), + (ENCRYPTION_AES128_GCM, '128-bit AES (GCM)'), + (ENCRYPTION_AES192_CBC, '192-bit AES (CBC)'), + (ENCRYPTION_AES192_GCM, '192-bit AES (GCM)'), + (ENCRYPTION_AES256_CBC, '256-bit AES (CBC)'), + (ENCRYPTION_AES256_GCM, '256-bit AES (GCM)'), + (ENCRYPTION_3DES, '3DES'), + (ENCRYPTION_3DES, 'DES'), + ) + + +class AuthenticationAlgorithmChoices(ChoiceSet): + AUTH_HMAC_SHA1 = 'hmac-sha1' + AUTH_HMAC_SHA256 = 'hmac-sha256' + AUTH_HMAC_SHA384 = 'hmac-sha384' + AUTH_HMAC_SHA512 = 'hmac-sha512' + AUTH_HMAC_MD5 = 'hmac-md5' + + CHOICES = ( + (AUTH_HMAC_SHA1, 'SHA-1 HMAC'), + (AUTH_HMAC_SHA256, 'SHA-256 HMAC'), + (AUTH_HMAC_SHA384, 'SHA-384 HMAC'), + (AUTH_HMAC_SHA512, 'SHA-512 HMAC'), + (AUTH_HMAC_MD5, 'MD5 HMAC'), + ) + + +class DHGroupChoices(ChoiceSet): + # https://www.iana.org/assignments/ikev2-parameters/ikev2-parameters.xhtml#ikev2-parameters-8 + GROUP_1 = 1 # 768-bit MODP + GROUP_2 = 2 # 1024-but MODP + # Groups 3-4 reserved + GROUP_5 = 5 # 1536-bit MODP + # Groups 6-13 unassigned + GROUP_14 = 14 # 2048-bit MODP + GROUP_15 = 15 # 3072-bit MODP + GROUP_16 = 16 # 4096-bit MODP + GROUP_17 = 17 # 6144-bit MODP + GROUP_18 = 18 # 8192-bit MODP + GROUP_19 = 19 # 256-bit random ECP + GROUP_20 = 20 # 384-bit random ECP + GROUP_21 = 21 # 521-bit random ECP (521 is not a typo) + GROUP_22 = 22 # 1024-bit MODP w/160-bit prime + GROUP_23 = 23 # 2048-bit MODP w/224-bit prime + GROUP_24 = 24 # 2048-bit MODP w/256-bit prime + GROUP_25 = 25 # 192-bit ECP + GROUP_26 = 26 # 224-bit ECP + GROUP_27 = 27 # brainpoolP224r1 + GROUP_28 = 28 # brainpoolP256r1 + GROUP_29 = 29 # brainpoolP384r1 + GROUP_30 = 30 # brainpoolP512r1 + GROUP_31 = 31 # Curve25519 + GROUP_32 = 32 # Curve448 + GROUP_33 = 33 # GOST3410_2012_256 + GROUP_34 = 34 # GOST3410_2012_512 + + CHOICES = ( + # Strings are formatted in this manner to optimize translations + (GROUP_1, _('Group {n}').format(n=1)), + (GROUP_2, _('Group {n}').format(n=2)), + (GROUP_5, _('Group {n}').format(n=5)), + (GROUP_14, _('Group {n}').format(n=14)), + (GROUP_16, _('Group {n}').format(n=16)), + (GROUP_17, _('Group {n}').format(n=17)), + (GROUP_18, _('Group {n}').format(n=18)), + (GROUP_19, _('Group {n}').format(n=19)), + (GROUP_20, _('Group {n}').format(n=20)), + (GROUP_21, _('Group {n}').format(n=21)), + (GROUP_22, _('Group {n}').format(n=22)), + (GROUP_23, _('Group {n}').format(n=23)), + (GROUP_24, _('Group {n}').format(n=24)), + (GROUP_25, _('Group {n}').format(n=25)), + (GROUP_26, _('Group {n}').format(n=26)), + (GROUP_27, _('Group {n}').format(n=27)), + (GROUP_28, _('Group {n}').format(n=28)), + (GROUP_29, _('Group {n}').format(n=29)), + (GROUP_30, _('Group {n}').format(n=30)), + (GROUP_31, _('Group {n}').format(n=31)), + (GROUP_32, _('Group {n}').format(n=32)), + (GROUP_33, _('Group {n}').format(n=33)), + (GROUP_34, _('Group {n}').format(n=34)), + ) diff --git a/netbox/vpn/filtersets.py b/netbox/vpn/filtersets.py new file mode 100644 index 000000000..c0bd140c3 --- /dev/null +++ b/netbox/vpn/filtersets.py @@ -0,0 +1,241 @@ +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 utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter +from virtualization.models import VMInterface +from .choices import * +from .models import * + +__all__ = ( + 'IKEPolicyFilterSet', + 'IKEProposalFilterSet', + 'IPSecPolicyFilterSet', + 'IPSecProfileFilterSet', + 'IPSecProposalFilterSet', + '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', '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=Tunnel.objects.all(), + to_field_name='name', + label=_('Tunnel (name)'), + ) + role = django_filters.MultipleChoiceFilter( + choices=TunnelTerminationRoleChoices + ) + termination_type = ContentTypeFilter() + interface = django_filters.ModelMultipleChoiceFilter( + field_name='interface__name', + queryset=Interface.objects.all(), + to_field_name='name', + label=_('Interface (name)'), + ) + interface_id = django_filters.ModelMultipleChoiceFilter( + field_name='interface', + queryset=Interface.objects.all(), + label=_('Interface (ID)'), + ) + vminterface = django_filters.ModelMultipleChoiceFilter( + field_name='vminterface__name', + queryset=VMInterface.objects.all(), + to_field_name='name', + label=_('VM interface (name)'), + ) + vminterface_id = django_filters.ModelMultipleChoiceFilter( + field_name='vminterface', + queryset=VMInterface.objects.all(), + label=_('VM interface (ID)'), + ) + outside_ip_id = django_filters.ModelMultipleChoiceFilter( + field_name='outside_ip', + queryset=IPAddress.objects.all(), + label=_('Outside IP (ID)'), + ) + + class Meta: + model = TunnelTermination + fields = ['id'] + + +class IKEProposalFilterSet(NetBoxModelFilterSet): + authentication_method = django_filters.MultipleChoiceFilter( + choices=AuthenticationMethodChoices + ) + encryption_algorithm = django_filters.MultipleChoiceFilter( + choices=EncryptionAlgorithmChoices + ) + authentication_algorithm = django_filters.MultipleChoiceFilter( + choices=AuthenticationAlgorithmChoices + ) + group = django_filters.MultipleChoiceFilter( + choices=DHGroupChoices + ) + + class Meta: + model = IKEProposal + fields = ['id', 'name', 'sa_lifetime'] + + 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 + ) + mode = django_filters.MultipleChoiceFilter( + choices=IKEModeChoices + ) + proposal_id = MultiValueNumberFilter( + field_name='proposals__id' + ) + proposal = MultiValueCharFilter( + field_name='proposals__name' + ) + + class Meta: + model = IKEPolicy + fields = ['id', 'name', 'preshared_key'] + + 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 + ) + authentication_algorithm = django_filters.MultipleChoiceFilter( + choices=AuthenticationAlgorithmChoices + ) + + 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 + ) + proposal_id = MultiValueNumberFilter( + field_name='proposals__id' + ) + proposal = MultiValueCharFilter( + field_name='proposals__name' + ) + + 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 + ) + 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'] + + 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 000000000..1499f98b2 --- /dev/null +++ b/netbox/vpn/forms/__init__.py @@ -0,0 +1,4 @@ +from .bulk_edit import * +from .bulk_import import * +from .filtersets import * +from .model_forms import * diff --git a/netbox/vpn/forms/bulk_edit.py b/netbox/vpn/forms/bulk_edit.py new file mode 100644 index 000000000..a7b097b5c --- /dev/null +++ b/netbox/vpn/forms/bulk_edit.py @@ -0,0 +1,243 @@ +from django import forms +from django.utils.translation import gettext_lazy as _ + +from netbox.forms import NetBoxModelBulkEditForm +from tenancy.models import Tenant +from utilities.forms import add_blank_choice +from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField +from vpn.choices import * +from vpn.models import * + +__all__ = ( + 'IKEPolicyBulkEditForm', + 'IKEProposalBulkEditForm', + 'IPSecPolicyBulkEditForm', + 'IPSecProfileBulkEditForm', + 'IPSecProposalBulkEditForm', + '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 + ) + 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',)), + (_('Tenancy'), ('tenant',)), + ) + nullable_fields = ( + 'ipsec_profile', 'tunnel_id', 'tenant', 'description', 'comments', + ) + + +class TunnelTerminationBulkEditForm(NetBoxModelBulkEditForm): + role = forms.ChoiceField( + label=_('Role'), + choices=add_blank_choice(TunnelTerminationRoleChoices), + required=False + ) + + model = TunnelTermination + + +class IKEProposalBulkEditForm(NetBoxModelBulkEditForm): + authentication_method = forms.ChoiceField( + label=_('Authentication method'), + choices=add_blank_choice(AuthenticationMethodChoices), + required=False + ) + encryption_algorithm = forms.ChoiceField( + label=_('Encryption algorithm'), + choices=add_blank_choice(EncryptionAlgorithmChoices), + required=False + ) + authentication_algorithm = forms.ChoiceField( + label=_('Authentication algorithm'), + choices=add_blank_choice(AuthenticationAlgorithmChoices), + required=False + ) + group = forms.ChoiceField( + label=_('Group'), + choices=add_blank_choice(DHGroupChoices), + required=False + ) + sa_lifetime = forms.IntegerField( + label=_('SA lifetime'), + required=False + ) + description = forms.CharField( + label=_('Description'), + max_length=200, + required=False + ) + comments = CommentField() + + model = IKEProposal + fieldsets = ( + (None, ( + 'authentication_method', 'encryption_algorithm', 'authentication_algorithm', 'group', 'sa_lifetime', + 'description', + )), + ) + nullable_fields = ( + 'sa_lifetime', 'description', 'comments', + ) + + +class IKEPolicyBulkEditForm(NetBoxModelBulkEditForm): + version = forms.ChoiceField( + label=_('Version'), + choices=add_blank_choice(IKEVersionChoices), + required=False + ) + mode = forms.ChoiceField( + label=_('Mode'), + choices=add_blank_choice(IKEModeChoices), + required=False + ) + preshared_key = forms.CharField( + label=_('Pre-shared key'), + required=False + ) + description = forms.CharField( + label=_('Description'), + max_length=200, + required=False + ) + comments = CommentField() + + model = IKEPolicy + fieldsets = ( + (None, ( + 'version', 'mode', 'preshared_key', 'description', + )), + ) + nullable_fields = ( + 'preshared_key', 'description', 'comments', + ) + + +class IPSecProposalBulkEditForm(NetBoxModelBulkEditForm): + encryption_algorithm = forms.ChoiceField( + label=_('Encryption algorithm'), + choices=add_blank_choice(EncryptionAlgorithmChoices), + required=False + ) + authentication_algorithm = forms.ChoiceField( + label=_('Authentication algorithm'), + choices=add_blank_choice(AuthenticationAlgorithmChoices), + required=False + ) + sa_lifetime_seconds = forms.IntegerField( + label=_('SA lifetime (seconds)'), + required=False + ) + sa_lifetime_data = forms.IntegerField( + label=_('SA lifetime (KB)'), + required=False + ) + description = forms.CharField( + label=_('Description'), + max_length=200, + required=False + ) + comments = CommentField() + + model = IPSecProposal + fieldsets = ( + (None, ( + 'encryption_algorithm', 'authentication_algorithm', 'sa_lifetime_seconds', 'sa_lifetime_data', + 'description', + )), + ) + nullable_fields = ( + 'sa_lifetime_seconds', 'sa_lifetime_data', 'description', 'comments', + ) + + +class IPSecPolicyBulkEditForm(NetBoxModelBulkEditForm): + pfs_group = forms.ChoiceField( + label=_('PFS group'), + choices=add_blank_choice(DHGroupChoices), + required=False + ) + description = forms.CharField( + label=_('Description'), + max_length=200, + required=False + ) + comments = CommentField() + + model = IPSecPolicy + fieldsets = ( + (None, ('pfs_group', 'description',)), + ) + nullable_fields = ( + 'pfs_group', 'description', 'comments', + ) + + +class IPSecProfileBulkEditForm(NetBoxModelBulkEditForm): + mode = forms.ChoiceField( + label=_('Mode'), + choices=add_blank_choice(IPSecModeChoices), + required=False + ) + ike_policy = DynamicModelChoiceField( + label=_('IKE policy'), + queryset=IKEPolicy.objects.all(), + required=False + ) + ipsec_policy = DynamicModelChoiceField( + label=_('IPSec policy'), + queryset=IPSecPolicy.objects.all(), + required=False + ) + description = forms.CharField( + label=_('Description'), + max_length=200, + required=False + ) + comments = CommentField() + + model = IPSecProfile + fieldsets = ( + (_('Profile'), ( + 'mode', 'ike_policy', 'ipsec_policy', 'description', + )), + ) + nullable_fields = ( + 'description', 'comments', + ) diff --git a/netbox/vpn/forms/bulk_import.py b/netbox/vpn/forms/bulk_import.py new file mode 100644 index 000000000..5b42cc761 --- /dev/null +++ b/netbox/vpn/forms/bulk_import.py @@ -0,0 +1,230 @@ +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, CSVModelMultipleChoiceField +from virtualization.models import VirtualMachine, VMInterface +from vpn.choices import * +from vpn.models import * + +__all__ = ( + 'IKEPolicyImportForm', + 'IKEProposalImportForm', + 'IPSecPolicyImportForm', + 'IPSecProfileImportForm', + 'IPSecProposalImportForm', + '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(), + required=False, + to_field_name='name' + ) + tenant = CSVModelChoiceField( + label=_('Tenant'), + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text=_('Assigned tenant') + ) + + class Meta: + model = Tunnel + fields = ( + 'name', 'status', 'encapsulation', 'ipsec_profile', 'tenant', 'tunnel_id', 'description', 'comments', + 'tags', + ) + + +class TunnelTerminationImportForm(NetBoxModelImportForm): + tunnel = CSVModelChoiceField( + label=_('Tunnel'), + queryset=Tunnel.objects.all(), + to_field_name='name' + ) + role = CSVChoiceField( + label=_('Role'), + choices=TunnelTerminationRoleChoices, + help_text=_('Operational role') + ) + device = CSVModelChoiceField( + label=_('Device'), + queryset=Device.objects.all(), + required=False, + to_field_name='name', + help_text=_('Parent device of assigned interface') + ) + virtual_machine = CSVModelChoiceField( + label=_('Virtual machine'), + queryset=VirtualMachine.objects.all(), + required=False, + to_field_name='name', + help_text=_('Parent VM of assigned interface') + ) + termination = CSVModelChoiceField( + label=_('Termination'), + queryset=Interface.objects.none(), # Can also refer to VMInterface + required=False, + to_field_name='name', + help_text=_('Device or virtual machine interface') + ) + outside_ip = CSVModelChoiceField( + label=_('Outside IP'), + queryset=IPAddress.objects.all(), + required=False, + to_field_name='name' + ) + + class Meta: + model = TunnelTermination + fields = ( + 'tunnel', 'role', 'outside_ip', 'tags', + ) + + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) + + if data: + + # Limit termination queryset by assigned device/VM + if data.get('device'): + self.fields['termination'].queryset = Interface.objects.filter( + **{f"device__{self.fields['device'].to_field_name}": data['device']} + ) + elif data.get('virtual_machine'): + self.fields['termination'].queryset = VMInterface.objects.filter( + **{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']} + ) + + def save(self, *args, **kwargs): + + # Assign termination object + if self.cleaned_data.get('termination'): + self.instance.termination = self.cleaned_data['termination'] + + return super().save(*args, **kwargs) + + +class IKEProposalImportForm(NetBoxModelImportForm): + authentication_method = CSVChoiceField( + label=_('Authentication method'), + choices=AuthenticationMethodChoices + ) + encryption_algorithm = CSVChoiceField( + label=_('Encryption algorithm'), + choices=EncryptionAlgorithmChoices + ) + authentication_algorithm = CSVChoiceField( + label=_('Authentication algorithm'), + choices=AuthenticationAlgorithmChoices + ) + group = CSVChoiceField( + label=_('Group'), + choices=DHGroupChoices + ) + + class Meta: + model = IKEProposal + fields = ( + 'name', 'description', 'authentication_method', 'encryption_algorithm', 'authentication_algorithm', + 'group', 'sa_lifetime', 'tags', + ) + + +class IKEPolicyImportForm(NetBoxModelImportForm): + version = CSVChoiceField( + label=_('Version'), + choices=IKEVersionChoices + ) + mode = CSVChoiceField( + label=_('Mode'), + choices=IKEModeChoices + ) + proposals = CSVModelMultipleChoiceField( + queryset=IKEProposal.objects.all(), + to_field_name='name', + help_text=_('IKE proposal(s)'), + ) + + class Meta: + model = IKEPolicy + fields = ( + 'name', 'description', 'version', 'mode', 'proposals', 'preshared_key', 'tags', + ) + + +class IPSecProposalImportForm(NetBoxModelImportForm): + encryption_algorithm = CSVChoiceField( + label=_('Encryption algorithm'), + choices=EncryptionAlgorithmChoices + ) + authentication_algorithm = CSVChoiceField( + label=_('Authentication algorithm'), + choices=AuthenticationAlgorithmChoices + ) + + class Meta: + model = IPSecProposal + fields = ( + 'name', 'description', 'encryption_algorithm', 'authentication_algorithm', 'sa_lifetime_seconds', + 'sa_lifetime_data', 'tags', + ) + + +class IPSecPolicyImportForm(NetBoxModelImportForm): + pfs_group = CSVChoiceField( + label=_('Diffie-Hellman group for Perfect Forward Secrecy'), + choices=DHGroupChoices + ) + proposals = CSVModelMultipleChoiceField( + queryset=IPSecProposal.objects.all(), + to_field_name='name', + help_text=_('IPSec proposal(s)'), + ) + + class Meta: + model = IPSecPolicy + fields = ( + 'name', 'description', 'proposals', 'pfs_group', 'tags', + ) + + +class IPSecProfileImportForm(NetBoxModelImportForm): + mode = CSVChoiceField( + label=_('Mode'), + choices=IPSecModeChoices, + help_text=_('IPSec protocol') + ) + ike_policy = CSVModelChoiceField( + label=_('IKE policy'), + queryset=IKEPolicy.objects.all(), + to_field_name='name' + ) + ipsec_policy = CSVModelChoiceField( + label=_('IPSec policy'), + queryset=IPSecPolicy.objects.all(), + to_field_name='name' + ) + + class Meta: + model = IPSecProfile + fields = ( + 'name', 'mode', 'ike_policy', 'ipsec_policy', 'description', 'comments', 'tags', + ) diff --git a/netbox/vpn/forms/filtersets.py b/netbox/vpn/forms/filtersets.py new file mode 100644 index 000000000..ec146919a --- /dev/null +++ b/netbox/vpn/forms/filtersets.py @@ -0,0 +1,182 @@ +from django import forms +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 * + +__all__ = ( + 'IKEPolicyFilterForm', + 'IKEProposalFilterForm', + 'IPSecPolicyFilterForm', + 'IPSecProfileFilterForm', + 'IPSecProposalFilterForm', + 'TunnelFilterForm', + 'TunnelTerminationFilterForm', +) + + +class TunnelFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): + model = Tunnel + fieldsets = ( + (None, ('q', 'filter_id', 'tag')), + (_('Tunnel'), ('status', 'encapsulation', 'tunnel_id')), + (_('Security'), ('ipsec_profile_id',)), + (_('Tenancy'), ('tenant_group_id', 'tenant_id')), + ) + status = forms.MultipleChoiceField( + label=_('Status'), + choices=TunnelStatusChoices, + required=False + ) + encapsulation = forms.MultipleChoiceField( + label=_('Encapsulation'), + choices=TunnelEncapsulationChoices, + required=False + ) + ipsec_profile_id = DynamicModelMultipleChoiceField( + queryset=IPSecProfile.objects.all(), + required=False, + label=_('IPSec profile') + ) + tunnel_id = forms.IntegerField( + required=False, + label=_('Tunnel ID') + ) + tag = TagFilterField(model) + + +class TunnelTerminationFilterForm(NetBoxModelFilterSetForm): + model = TunnelTermination + fieldsets = ( + (None, ('q', 'filter_id', 'tag')), + (_('Termination'), ('tunnel_id', 'role')), + ) + tunnel_id = DynamicModelMultipleChoiceField( + queryset=Tunnel.objects.all(), + required=False, + label=_('Tunnel') + ) + role = forms.MultipleChoiceField( + label=_('Role'), + choices=TunnelTerminationRoleChoices, + required=False + ) + tag = TagFilterField(model) + + +class IKEProposalFilterForm(NetBoxModelFilterSetForm): + model = IKEProposal + fieldsets = ( + (None, ('q', 'filter_id', 'tag')), + (_('Parameters'), ('authentication_method', 'encryption_algorithm', 'authentication_algorithm', 'group')), + ) + authentication_method = forms.MultipleChoiceField( + label=_('Authentication method'), + choices=AuthenticationMethodChoices, + required=False + ) + encryption_algorithm = forms.MultipleChoiceField( + label=_('Encryption algorithm'), + choices=EncryptionAlgorithmChoices, + required=False + ) + authentication_algorithm = forms.MultipleChoiceField( + label=_('Authentication algorithm'), + choices=AuthenticationAlgorithmChoices, + required=False + ) + group = forms.MultipleChoiceField( + label=_('Group'), + choices=DHGroupChoices, + required=False + ) + tag = TagFilterField(model) + + +class IKEPolicyFilterForm(NetBoxModelFilterSetForm): + model = IKEPolicy + fieldsets = ( + (None, ('q', 'filter_id', 'tag')), + (_('Parameters'), ('version', 'mode', 'proposal_id')), + ) + version = forms.MultipleChoiceField( + label=_('IKE version'), + choices=IKEVersionChoices, + required=False + ) + mode = forms.MultipleChoiceField( + label=_('Mode'), + choices=IKEModeChoices, + required=False + ) + proposal_id = DynamicModelMultipleChoiceField( + queryset=IKEProposal.objects.all(), + required=False, + label=_('Proposal') + ) + tag = TagFilterField(model) + + +class IPSecProposalFilterForm(NetBoxModelFilterSetForm): + model = IPSecProposal + fieldsets = ( + (None, ('q', 'filter_id', 'tag')), + (_('Parameters'), ('encryption_algorithm', 'authentication_algorithm')), + ) + encryption_algorithm = forms.MultipleChoiceField( + label=_('Encryption algorithm'), + choices=EncryptionAlgorithmChoices, + required=False + ) + authentication_algorithm = forms.MultipleChoiceField( + label=_('Authentication algorithm'), + choices=AuthenticationAlgorithmChoices, + required=False + ) + tag = TagFilterField(model) + + +class IPSecPolicyFilterForm(NetBoxModelFilterSetForm): + model = IPSecPolicy + fieldsets = ( + (None, ('q', 'filter_id', 'tag')), + (_('Parameters'), ('proposal_id', 'pfs_group')), + ) + proposal_id = DynamicModelMultipleChoiceField( + queryset=IKEProposal.objects.all(), + required=False, + label=_('Proposal') + ) + pfs_group = forms.MultipleChoiceField( + label=_('Mode'), + choices=DHGroupChoices, + required=False + ) + tag = TagFilterField(model) + + +class IPSecProfileFilterForm(NetBoxModelFilterSetForm): + model = IPSecProfile + fieldsets = ( + (None, ('q', 'filter_id', 'tag')), + (_('Profile'), ('mode', 'ike_policy_id', 'ipsec_policy_id')), + ) + mode = forms.MultipleChoiceField( + label=_('Mode'), + choices=IPSecModeChoices, + required=False + ) + ike_policy_id = DynamicModelMultipleChoiceField( + queryset=IKEPolicy.objects.all(), + required=False, + label=_('IKE policy') + ) + ipsec_policy_id = DynamicModelMultipleChoiceField( + queryset=IPSecPolicy.objects.all(), + required=False, + label=_('IPSec policy') + ) + tag = TagFilterField(model) diff --git a/netbox/vpn/forms/model_forms.py b/netbox/vpn/forms/model_forms.py new file mode 100644 index 000000000..35fa2cad3 --- /dev/null +++ b/netbox/vpn/forms/model_forms.py @@ -0,0 +1,357 @@ +from django import forms +from django.utils.translation import gettext_lazy as _ + +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, 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 * +from vpn.models import * + +__all__ = ( + 'IKEPolicyForm', + 'IKEProposalForm', + 'IPSecPolicyForm', + 'IPSecProfileForm', + 'IPSecProposalForm', + 'TunnelCreateForm', + 'TunnelForm', + 'TunnelTerminationForm', +) + + +class TunnelForm(TenancyForm, NetBoxModelForm): + ipsec_profile = DynamicModelChoiceField( + queryset=IPSecProfile.objects.all(), + label=_('IPSec Profile'), + required=False + ) + comments = CommentField() + + fieldsets = ( + (_('Tunnel'), ('name', 'status', 'encapsulation', 'description', 'tunnel_id', 'tags')), + (_('Security'), ('ipsec_profile',)), + (_('Tenancy'), ('tenant_group', 'tenant')), + ) + + class Meta: + model = Tunnel + fields = [ + 'name', 'status', 'encapsulation', 'description', 'tunnel_id', 'ipsec_profile', 'tenant_group', 'tenant', + 'comments', 'tags', + ] + + +class TunnelCreateForm(TunnelForm): + # First termination + termination1_role = forms.ChoiceField( + choices=add_blank_choice(TunnelTerminationRoleChoices), + required=False, + label=_('Role') + ) + termination1_type = forms.ChoiceField( + choices=TunnelTerminationTypeChoices, + required=False, + widget=HTMXSelect(), + label=_('Type') + ) + termination1_parent = DynamicModelChoiceField( + queryset=Device.objects.all(), + required=False, + selector=True, + label=_('Device') + ) + termination1_termination = DynamicModelChoiceField( + queryset=Interface.objects.all(), + required=False, + label=_('Interface'), + query_params={ + 'device_id': '$termination1_parent', + } + ) + termination1_outside_ip = DynamicModelChoiceField( + queryset=IPAddress.objects.all(), + label=_('Outside IP'), + required=False, + query_params={ + 'device_id': '$termination1_parent', + } + ) + + # Second termination + termination2_role = forms.ChoiceField( + choices=add_blank_choice(TunnelTerminationRoleChoices), + required=False, + label=_('Role') + ) + termination2_type = forms.ChoiceField( + choices=TunnelTerminationTypeChoices, + required=False, + widget=HTMXSelect(), + label=_('Type') + ) + termination2_parent = DynamicModelChoiceField( + queryset=Device.objects.all(), + required=False, + selector=True, + label=_('Device') + ) + termination2_termination = DynamicModelChoiceField( + queryset=Interface.objects.all(), + required=False, + label=_('Interface'), + query_params={ + 'device_id': '$termination2_parent', + } + ) + termination2_outside_ip = DynamicModelChoiceField( + queryset=IPAddress.objects.all(), + required=False, + label=_('Outside IP'), + query_params={ + 'device_id': '$termination2_parent', + } + ) + + fieldsets = ( + (_('Tunnel'), ('name', 'status', 'encapsulation', 'description', 'tunnel_id', 'tags')), + (_('Security'), ('ipsec_profile',)), + (_('Tenancy'), ('tenant_group', 'tenant')), + (_('First Termination'), ( + 'termination1_role', 'termination1_type', 'termination1_parent', 'termination1_termination', + 'termination1_outside_ip', + )), + (_('Second Termination'), ( + 'termination2_role', 'termination2_type', 'termination2_parent', 'termination2_termination', + 'termination2_outside_ip', + )), + ) + + def __init__(self, *args, initial=None, **kwargs): + super().__init__(*args, initial=initial, **kwargs) + + if initial and initial.get('termination1_type') == TunnelTerminationTypeChoices.TYPE_VIRUTALMACHINE: + self.fields['termination1_parent'].label = _('Virtual Machine') + self.fields['termination1_parent'].queryset = VirtualMachine.objects.all() + self.fields['termination1_termination'].queryset = VMInterface.objects.all() + self.fields['termination1_termination'].widget.add_query_params({ + 'virtual_machine_id': '$termination1_parent', + }) + self.fields['termination1_outside_ip'].widget.add_query_params({ + 'virtual_machine_id': '$termination1_parent', + }) + + if initial and initial.get('termination2_type') == TunnelTerminationTypeChoices.TYPE_VIRUTALMACHINE: + self.fields['termination2_parent'].label = _('Virtual Machine') + self.fields['termination2_parent'].queryset = VirtualMachine.objects.all() + self.fields['termination2_termination'].queryset = VMInterface.objects.all() + self.fields['termination2_termination'].widget.add_query_params({ + 'virtual_machine_id': '$termination2_parent', + }) + self.fields['termination2_outside_ip'].widget.add_query_params({ + 'virtual_machine_id': '$termination2_parent', + }) + + def clean(self): + super().clean() + + # Validate attributes for each termination (if any) + for term in ('termination1', 'termination2'): + required_parameters = ( + f'{term}_role', f'{term}_parent', f'{term}_termination', + ) + parameters = ( + *required_parameters, + f'{term}_outside_ip', + ) + if any([self.cleaned_data[param] for param in parameters]): + for param in required_parameters: + if not self.cleaned_data[param]: + raise forms.ValidationError({ + param: _("This parameter is required when defining a termination.") + }) + + def save(self, *args, **kwargs): + instance = super().save(*args, **kwargs) + + # Create first termination + if self.cleaned_data['termination1_termination']: + TunnelTermination.objects.create( + tunnel=instance, + role=self.cleaned_data['termination1_role'], + termination=self.cleaned_data['termination1_termination'], + outside_ip=self.cleaned_data['termination1_outside_ip'], + ) + + # Create second termination, if defined + if self.cleaned_data['termination2_termination']: + TunnelTermination.objects.create( + tunnel=instance, + role=self.cleaned_data['termination2_role'], + termination=self.cleaned_data['termination2_termination'], + outside_ip=self.cleaned_data.get('termination1_outside_ip'), + ) + + return instance + + +class TunnelTerminationForm(NetBoxModelForm): + tunnel = DynamicModelChoiceField( + queryset=Tunnel.objects.all() + ) + type = forms.ChoiceField( + choices=TunnelTerminationTypeChoices, + widget=HTMXSelect(), + label=_('Type') + ) + parent = DynamicModelChoiceField( + queryset=Device.objects.all(), + selector=True, + label=_('Device') + ) + termination = DynamicModelChoiceField( + queryset=Interface.objects.all(), + label=_('Interface'), + query_params={ + 'device_id': '$parent', + } + ) + outside_ip = DynamicModelChoiceField( + queryset=IPAddress.objects.all(), + label=_('Outside IP'), + required=False, + query_params={ + 'device_id': '$parent', + } + ) + + fieldsets = ( + (None, ('tunnel', 'role', 'type', 'parent', 'termination', 'outside_ip', 'tags')), + ) + + class Meta: + model = TunnelTermination + fields = [ + 'tunnel', 'role', 'termination', 'outside_ip', 'tags', + ] + + def __init__(self, *args, initial=None, **kwargs): + super().__init__(*args, initial=initial, **kwargs) + + if initial and initial.get('type') == TunnelTerminationTypeChoices.TYPE_VIRUTALMACHINE: + self.fields['parent'].label = _('Virtual Machine') + self.fields['parent'].queryset = VirtualMachine.objects.all() + self.fields['termination'].queryset = VMInterface.objects.all() + self.fields['termination'].widget.add_query_params({ + 'virtual_machine_id': '$parent', + }) + self.fields['outside_ip'].widget.add_query_params({ + 'virtual_machine_id': '$parent', + }) + + if self.instance.pk: + self.fields['parent'].initial = self.instance.termination.parent_object + self.fields['termination'].initial = self.instance.termination + + def clean(self): + super().clean() + + # Set the terminated object + self.instance.termination = self.cleaned_data.get('termination') + + +class IKEProposalForm(NetBoxModelForm): + + fieldsets = ( + (_('Proposal'), ('name', 'description', 'tags')), + (_('Parameters'), ( + 'authentication_method', 'encryption_algorithm', 'authentication_algorithm', 'group', 'sa_lifetime', + )), + ) + + class Meta: + model = IKEProposal + fields = [ + 'name', 'description', 'authentication_method', 'encryption_algorithm', 'authentication_algorithm', 'group', + 'sa_lifetime', 'tags', + ] + + +class IKEPolicyForm(NetBoxModelForm): + proposals = DynamicModelMultipleChoiceField( + queryset=IKEProposal.objects.all(), + label=_('Proposals') + ) + + fieldsets = ( + (_('Policy'), ('name', 'description', 'tags')), + (_('Parameters'), ('version', 'mode', 'proposals', 'preshared_key')), + ) + + class Meta: + model = IKEPolicy + fields = [ + 'name', 'description', 'version', 'mode', 'proposals', 'preshared_key', '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(), + label=_('Proposals') + ) + + fieldsets = ( + (_('Policy'), ('name', 'description', 'tags')), + (_('Parameters'), ('proposals', 'pfs_group')), + ) + + class Meta: + model = IPSecPolicy + fields = [ + 'name', 'description', 'proposals', 'pfs_group', 'tags', + ] + + +class IPSecProfileForm(NetBoxModelForm): + ike_policy = DynamicModelChoiceField( + queryset=IKEPolicy.objects.all(), + label=_('IKE policy') + ) + ipsec_policy = DynamicModelChoiceField( + queryset=IPSecPolicy.objects.all(), + label=_('IPSec policy') + ) + comments = CommentField() + + fieldsets = ( + (_('Profile'), ('name', 'description', 'tags')), + (_('Parameters'), ('mode', 'ike_policy', 'ipsec_policy')), + ) + + class Meta: + model = IPSecProfile + fields = [ + 'name', 'description', 'mode', 'ike_policy', 'ipsec_policy', 'description', 'comments', 'tags', + ] diff --git a/netbox/vpn/graphql/__init__.py b/netbox/vpn/graphql/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/vpn/graphql/schema.py b/netbox/vpn/graphql/schema.py new file mode 100644 index 000000000..64e680882 --- /dev/null +++ b/netbox/vpn/graphql/schema.py @@ -0,0 +1,51 @@ +import graphene + +from netbox.graphql.fields import ObjectField, ObjectListField +from utilities.graphql_optimizer import gql_query_optimizer +from vpn import models +from .types import * + + +class VPNQuery(graphene.ObjectType): + + ike_policy = ObjectField(IKEPolicyType) + ike_policy_list = ObjectListField(IKEPolicyType) + + def resolve_ike_policy_list(root, info, **kwargs): + return gql_query_optimizer(models.IKEPolicy.objects.all(), info) + + ike_proposal = ObjectField(IKEProposalType) + ike_proposal_list = ObjectListField(IKEProposalType) + + def resolve_ike_proposal_list(root, info, **kwargs): + return gql_query_optimizer(models.IKEProposal.objects.all(), info) + + ipsec_policy = ObjectField(IPSecPolicyType) + ipsec_policy_list = ObjectListField(IPSecPolicyType) + + def resolve_ipsec_policy_list(root, info, **kwargs): + return gql_query_optimizer(models.IPSecPolicy.objects.all(), info) + + ipsec_profile = ObjectField(IPSecProfileType) + ipsec_profile_list = ObjectListField(IPSecProfileType) + + def resolve_ipsec_profile_list(root, info, **kwargs): + return gql_query_optimizer(models.IPSecProfile.objects.all(), info) + + ipsec_proposal = ObjectField(IPSecProposalType) + ipsec_proposal_list = ObjectListField(IPSecProposalType) + + def resolve_ipsec_proposal_list(root, info, **kwargs): + return gql_query_optimizer(models.IPSecProposal.objects.all(), info) + + 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 000000000..f46e8b697 --- /dev/null +++ b/netbox/vpn/graphql/types.py @@ -0,0 +1,69 @@ +from extras.graphql.mixins import CustomFieldsMixin, TagsMixin +from netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObjectType +from vpn import filtersets, models + +__all__ = ( + 'IKEPolicyType', + 'IKEProposalType', + 'IPSecPolicyType', + 'IPSecProfileType', + 'IPSecProposalType', + '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 IKEProposalType(OrganizationalObjectType): + + class Meta: + model = models.IKEProposal + fields = '__all__' + filterset_class = filtersets.IKEProposalFilterSet + + +class IKEPolicyType(OrganizationalObjectType): + + class Meta: + model = models.IKEPolicy + fields = '__all__' + filterset_class = filtersets.IKEPolicyFilterSet + + +class IPSecProposalType(OrganizationalObjectType): + + class Meta: + model = models.IPSecProposal + fields = '__all__' + filterset_class = filtersets.IPSecProposalFilterSet + + +class IPSecPolicyType(OrganizationalObjectType): + + class Meta: + model = models.IPSecPolicy + fields = '__all__' + filterset_class = filtersets.IPSecPolicyFilterSet + + +class IPSecProfileType(OrganizationalObjectType): + + class Meta: + model = models.IPSecProfile + fields = '__all__' + filterset_class = filtersets.IPSecProfileFilterSet diff --git a/netbox/vpn/migrations/0001_initial.py b/netbox/vpn/migrations/0001_initial.py new file mode 100644 index 000000000..f5d9ae0c1 --- /dev/null +++ b/netbox/vpn/migrations/0001_initial.py @@ -0,0 +1,186 @@ +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)), + ], + 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)), + ('termination_id', models.PositiveBigIntegerField(blank=True, null=True)), + ('termination_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')), + ('outside_ip', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tunnel_termination', to='ipam.ipaddress')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ('tunnel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='vpn.tunnel')), + ], + options={ + 'verbose_name': 'tunnel termination', + 'verbose_name_plural': 'tunnel terminations', + 'ordering': ('tunnel', 'role', 'pk'), + }, + ), + migrations.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=('termination_type', 'termination_id'), name='vpn_tunneltermination_termination', violation_error_message='An object may be terminated to only one tunnel at a time.'), + ), + ] diff --git a/netbox/vpn/migrations/__init__.py b/netbox/vpn/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/vpn/models/__init__.py b/netbox/vpn/models/__init__.py new file mode 100644 index 000000000..3b70eb418 --- /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 000000000..1954dc6a0 --- /dev/null +++ b/netbox/vpn/models/crypto.py @@ -0,0 +1,254 @@ +from django.db import models +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ + +from netbox.models import NetBoxModel, PrimaryModel +from vpn.choices import * + +__all__ = ( + 'IKEPolicy', + 'IKEProposal', + 'IPSecPolicy', + 'IPSecProfile', + 'IPSecProposal', +) + + +# +# IKE +# + +class IKEProposal(NetBoxModel): + name = models.CharField( + verbose_name=_('name'), + max_length=100, + unique=True + ) + description = models.CharField( + verbose_name=_('description'), + max_length=200, + blank=True + ) + authentication_method = models.CharField( + verbose_name=('authentication method'), + choices=AuthenticationMethodChoices + ) + encryption_algorithm = models.CharField( + verbose_name=_('encryption algorithm'), + choices=EncryptionAlgorithmChoices + ) + authentication_algorithm = models.CharField( + verbose_name=_('authentication algorithm'), + choices=AuthenticationAlgorithmChoices + ) + group = models.PositiveSmallIntegerField( + verbose_name=_('group'), + choices=DHGroupChoices, + help_text=_('Diffie-Hellman group ID') + ) + sa_lifetime = models.PositiveIntegerField( + verbose_name=_('SA lifetime'), + blank=True, + null=True, + help_text=_('Security association lifetime (in seconds)') + ) + + clone_fields = ( + 'authentication_method', 'encryption_algorithm', 'authentication_algorithm', 'group', 'sa_lifetime', + ) + + class Meta: + ordering = ('name',) + verbose_name = _('IKE proposal') + verbose_name_plural = _('IKE proposals') + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('vpn:ikeproposal', args=[self.pk]) + + +class IKEPolicy(NetBoxModel): + name = models.CharField( + verbose_name=_('name'), + max_length=100, + unique=True + ) + description = models.CharField( + verbose_name=_('description'), + max_length=200, + blank=True + ) + version = models.PositiveSmallIntegerField( + verbose_name=_('version'), + choices=IKEVersionChoices, + default=IKEVersionChoices.VERSION_2 + ) + mode = models.CharField( + verbose_name=_('mode'), + choices=IKEModeChoices + ) + proposals = models.ManyToManyField( + to='vpn.IKEProposal', + related_name='ike_policies', + verbose_name=_('proposals') + ) + preshared_key = models.TextField( + verbose_name=_('pre-shared key'), + blank=True + ) + + clone_fields = ( + 'version', 'mode', 'proposals', + ) + prerequisite_models = ( + 'vpn.IKEProposal', + ) + + class Meta: + ordering = ('name',) + verbose_name = _('IKE policy') + verbose_name_plural = _('IKE policies') + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('vpn:ikepolicy', args=[self.pk]) + + +# +# IPSec +# + +class IPSecProposal(NetBoxModel): + name = models.CharField( + verbose_name=_('name'), + max_length=100, + unique=True + ) + description = models.CharField( + verbose_name=_('description'), + max_length=200, + blank=True + ) + 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)') + ) + sa_lifetime_data = models.PositiveIntegerField( + verbose_name=_('SA lifetime (KB)'), + blank=True, + null=True, + help_text=_('Security association lifetime (in kilobytes)') + ) + + clone_fields = ( + 'encryption_algorithm', 'authentication_algorithm', 'sa_lifetime_seconds', 'sa_lifetime_data', + ) + + class Meta: + ordering = ('name',) + verbose_name = _('IPSec proposal') + verbose_name_plural = _('IPSec proposals') + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('vpn:ipsecproposal', args=[self.pk]) + + +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') + ) + + clone_fields = ( + 'proposals', 'pfs_group', + ) + prerequisite_models = ( + 'vpn.IPSecProposal', + ) + + class Meta: + ordering = ('name',) + verbose_name = _('IPSec policy') + verbose_name_plural = _('IPSec policies') + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('vpn:ipsecpolicy', args=[self.pk]) + + +class IPSecProfile(PrimaryModel): + name = models.CharField( + verbose_name=_('name'), + max_length=100, + unique=True + ) + mode = models.CharField( + verbose_name=_('mode'), + choices=IPSecModeChoices + ) + ike_policy = models.ForeignKey( + to='vpn.IKEPolicy', + on_delete=models.PROTECT, + related_name='ipsec_profiles' + ) + ipsec_policy = models.ForeignKey( + to='vpn.IPSecPolicy', + on_delete=models.PROTECT, + related_name='ipsec_profiles' + ) + + clone_fields = ( + 'mode', 'ike_policy', 'ipsec_policy', + ) + prerequisite_models = ( + 'vpn.IKEPolicy', + 'vpn.IPSecPolicy', + ) + + class Meta: + ordering = ('name',) + verbose_name = _('IPSec profile') + verbose_name_plural = _('IPSec profiles') + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('vpn:ipsecprofile', args=[self.pk]) diff --git a/netbox/vpn/models/tunnels.py b/netbox/vpn/models/tunnels.py new file mode 100644 index 000000000..f7390d0b4 --- /dev/null +++ b/netbox/vpn/models/tunnels.py @@ -0,0 +1,146 @@ +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 _ + +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 + ) + tunnel_id = models.PositiveBigIntegerField( + verbose_name=_('tunnel ID'), + blank=True, + null=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 + ) + termination_type = models.ForeignKey( + to='contenttypes.ContentType', + on_delete=models.PROTECT, + related_name='+' + ) + termination_id = models.PositiveBigIntegerField( + blank=True, + null=True + ) + termination = GenericForeignKey( + ct_field='termination_type', + fk_field='termination_id' + ) + outside_ip = models.OneToOneField( + to='ipam.IPAddress', + on_delete=models.PROTECT, + related_name='tunnel_termination', + blank=True, + null=True + ) + + prerequisite_models = ( + 'vpn.Tunnel', + ) + + class Meta: + ordering = ('tunnel', 'role', 'pk') + constraints = ( + models.UniqueConstraint( + fields=('termination_type', 'termination_id'), + name='%(app_label)s_%(class)s_termination', + violation_error_message=_("An object may be terminated to only one tunnel at a time.") + ), + ) + verbose_name = _('tunnel termination') + verbose_name_plural = _('tunnel terminations') + + def __str__(self): + return f'{self.tunnel}: Termination {self.pk}' + + def get_absolute_url(self): + return reverse('vpn:tunneltermination', args=[self.pk]) + + def get_role_color(self): + return TunnelTerminationRoleChoices.colors.get(self.role) + + def clean(self): + super().clean() + + # Check that the selected termination object is not already attached to a Tunnel + if getattr(self.termination, 'tunnel_termination', None) and self.termination.tunnel_termination.pk != self.pk: + raise ValidationError({ + 'termination': _("{name} is already attached to a tunnel ({tunnel}).").format( + name=self.termination.name, + tunnel=self.termination.tunnel_termination.tunnel + ) + }) + + def to_objectchange(self, action): + objectchange = super().to_objectchange(action) + objectchange.related_object = self.tunnel + return objectchange diff --git a/netbox/vpn/search.py b/netbox/vpn/search.py new file mode 100644 index 000000000..70b0c644f --- /dev/null +++ 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',) diff --git a/netbox/vpn/tables.py b/netbox/vpn/tables.py new file mode 100644 index 000000000..304467586 --- /dev/null +++ b/netbox/vpn/tables.py @@ -0,0 +1,254 @@ +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__ = ( + 'IKEPolicyTable', + 'IKEProposalTable', + 'IPSecPolicyTable', + 'IPSecProposalTable', + 'IPSecProfileTable', + 'TunnelTable', + 'TunnelTerminationTable', +) + + +class TunnelTable(TenancyColumnsMixin, NetBoxTable): + name = tables.Column( + verbose_name=_('Name'), + linkify=True + ) + status = columns.ChoiceFieldColumn( + verbose_name=_('Status') + ) + ipsec_profile = tables.Column( + verbose_name=_('IPSec profile'), + linkify=True + ) + terminations_count = columns.LinkedCountColumn( + accessor=Accessor('count_terminations'), + viewname='vpn:tunneltermination_list', + url_params={'tunnel_id': 'pk'}, + verbose_name=_('Terminations') + ) + comments = columns.MarkdownColumn( + verbose_name=_('Comments'), + ) + tags = columns.TagColumn( + url_name='vpn:tunnel_list' + ) + + class Meta(NetBoxTable.Meta): + model = Tunnel + fields = ( + 'pk', 'id', 'name', 'status', 'encapsulation', 'ipsec_profile', 'tenant', 'tenant_group', 'tunnel_id', + 'termination_count', 'description', 'comments', 'tags', 'created', 'last_updated', + ) + default_columns = ('pk', 'name', 'status', 'encapsulation', 'ipsec_profile', 'tenant', 'terminations_count') + + +class TunnelTerminationTable(TenancyColumnsMixin, NetBoxTable): + tunnel = tables.Column( + verbose_name=_('Tunnel'), + linkify=True + ) + role = columns.ChoiceFieldColumn( + verbose_name=_('Role') + ) + termination_parent = tables.Column( + accessor='termination__parent_object', + linkify=True, + orderable=False, + verbose_name=_('Host') + ) + termination = tables.Column( + verbose_name=_('Termination'), + linkify=True + ) + ip_addresses = tables.ManyToManyColumn( + accessor=tables.A('termination__ip_addresses'), + orderable=False, + linkify_item=True, + verbose_name=_('IP Addresses') + ) + outside_ip = tables.Column( + verbose_name=_('Outside IP'), + linkify=True + ) + tags = columns.TagColumn( + url_name='vpn:tunneltermination_list' + ) + + class Meta(NetBoxTable.Meta): + model = TunnelTermination + fields = ( + 'pk', 'id', 'tunnel', 'role', 'termination_parent', 'termination', 'ip_addresses', 'outside_ip', 'tags', + 'created', 'last_updated', + ) + default_columns = ( + 'pk', 'id', 'tunnel', 'role', 'termination_parent', 'termination', 'ip_addresses', 'outside_ip', + ) + + +class IKEProposalTable(NetBoxTable): + name = tables.Column( + verbose_name=_('Name'), + linkify=True + ) + authentication_method = tables.Column( + verbose_name=_('Authentication Method') + ) + encryption_algorithm = tables.Column( + verbose_name=_('Encryption Algorithm') + ) + authentication_algorithm = tables.Column( + verbose_name=_('Authentication Algorithm') + ) + group = tables.Column( + verbose_name=_('Group') + ) + sa_lifetime = tables.Column( + verbose_name=_('SA Lifetime') + ) + 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 + ) + version = tables.Column( + verbose_name=_('Version') + ) + mode = tables.Column( + verbose_name=_('Mode') + ) + proposals = tables.ManyToManyColumn( + linkify_item=True, + verbose_name=_('Proposals') + ) + preshared_key = tables.Column( + verbose_name=_('Pre-shared Key') + ) + tags = columns.TagColumn( + url_name='vpn:ikepolicy_list' + ) + + class Meta(NetBoxTable.Meta): + model = IKEPolicy + fields = ( + 'pk', 'id', 'name', 'version', 'mode', 'proposals', 'preshared_key', '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 = tables.Column( + verbose_name=_('Encryption Algorithm') + ) + authentication_algorithm = tables.Column( + verbose_name=_('Authentication Algorithm') + ) + sa_lifetime_seconds = tables.Column( + verbose_name=_('SA Lifetime (Seconds)') + ) + sa_lifetime_data = tables.Column( + verbose_name=_('SA Lifetime (KB)') + ) + 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 = tables.Column( + 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', 'description', 'tags', 'created', 'last_updated', + ) + default_columns = ( + 'pk', 'name', 'proposals', 'pfs_group', 'description', + ) + + +class IPSecProfileTable(NetBoxTable): + name = tables.Column( + verbose_name=_('Name'), + linkify=True + ) + mode = tables.Column( + verbose_name=_('Mode') + ) + ike_policy = tables.Column( + linkify=True, + verbose_name=_('IKE Policy') + ) + ipsec_policy = tables.Column( + linkify=True, + verbose_name=_('IPSec Policy') + ) + comments = columns.MarkdownColumn( + verbose_name=_('Comments'), + ) + tags = columns.TagColumn( + url_name='vpn:ipsecprofile_list' + ) + + class Meta(NetBoxTable.Meta): + model = IPSecProfile + fields = ( + 'pk', 'id', 'name', 'mode', 'ike_policy', 'ipsec_policy', 'description', 'comments', 'tags', 'created', + 'last_updated', + ) + default_columns = ('pk', 'name', 'mode', 'ike_policy', 'ipsec_policy', 'description') diff --git a/netbox/vpn/tests/__init__.py b/netbox/vpn/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/vpn/tests/test_api.py b/netbox/vpn/tests/test_api.py new file mode 100644 index 000000000..9bfa297ab --- /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, + termination=interfaces[0] + ), + TunnelTermination( + tunnel=tunnel, + role=TunnelTerminationRoleChoices.ROLE_HUB, + termination=interfaces[1] + ), + TunnelTermination( + tunnel=tunnel, + role=TunnelTerminationRoleChoices.ROLE_HUB, + termination=interfaces[2] + ), + ) + TunnelTermination.objects.bulk_create(tunnel_terminations) + + cls.create_data = [ + { + 'tunnel': tunnel.pk, + 'role': TunnelTerminationRoleChoices.ROLE_PEER, + 'termination_type': 'dcim.interface', + 'termination_id': interfaces[3].pk, + }, + { + 'tunnel': tunnel.pk, + 'role': TunnelTerminationRoleChoices.ROLE_PEER, + 'termination_type': 'dcim.interface', + 'termination_id': interfaces[4].pk, + }, + { + 'tunnel': tunnel.pk, + 'role': TunnelTerminationRoleChoices.ROLE_PEER, + 'termination_type': 'dcim.interface', + 'termination_id': interfaces[5].pk, + }, + ] + + +class IKEProposalTest(APIViewTestCases.APIViewTestCase): + model = IKEProposal + brief_fields = ['display', 'id', 'name', 'url'] + bulk_update_data = { + 'authentication_method': AuthenticationMethodChoices.CERTIFICATES, + 'encryption_algorithm': EncryptionAlgorithmChoices.ENCRYPTION_AES192_CBC, + 'authentication_algorithm': AuthenticationAlgorithmChoices.AUTH_HMAC_MD5, + 'group': DHGroupChoices.GROUP_19, + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + + ike_proposals = ( + IKEProposal( + name='IKE Proposal 1', + authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS, + encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC, + authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1, + group=DHGroupChoices.GROUP_14 + ), + IKEProposal( + name='IKE Proposal 2', + authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS, + encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC, + authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1, + group=DHGroupChoices.GROUP_14 + ), + IKEProposal( + name='IKE Proposal 3', + authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS, + encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC, + authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1, + group=DHGroupChoices.GROUP_14 + ), + ) + IKEProposal.objects.bulk_create(ike_proposals) + + cls.create_data = [ + { + 'name': 'IKE Proposal 4', + 'authentication_method': AuthenticationMethodChoices.CERTIFICATES, + 'encryption_algorithm': EncryptionAlgorithmChoices.ENCRYPTION_AES256_CBC, + 'authentication_algorithm': AuthenticationAlgorithmChoices.AUTH_HMAC_SHA256, + 'group': DHGroupChoices.GROUP_19, + }, + { + 'name': 'IKE Proposal 5', + 'authentication_method': AuthenticationMethodChoices.CERTIFICATES, + 'encryption_algorithm': EncryptionAlgorithmChoices.ENCRYPTION_AES256_CBC, + 'authentication_algorithm': AuthenticationAlgorithmChoices.AUTH_HMAC_SHA256, + 'group': DHGroupChoices.GROUP_19, + }, + { + 'name': 'IKE Proposal 6', + 'authentication_method': AuthenticationMethodChoices.CERTIFICATES, + 'encryption_algorithm': EncryptionAlgorithmChoices.ENCRYPTION_AES256_CBC, + 'authentication_algorithm': AuthenticationAlgorithmChoices.AUTH_HMAC_SHA256, + 'group': DHGroupChoices.GROUP_19, + }, + ] + + +class IKEPolicyTest(APIViewTestCases.APIViewTestCase): + model = IKEPolicy + brief_fields = ['display', 'id', 'name', 'url'] + bulk_update_data = { + 'version': IKEVersionChoices.VERSION_1, + 'mode': IKEModeChoices.AGGRESSIVE, + 'description': 'New description', + 'preshared_key': 'New key', + } + + @classmethod + def setUpTestData(cls): + + ike_proposals = ( + IKEProposal( + name='IKE Proposal 1', + authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS, + encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC, + authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1, + group=DHGroupChoices.GROUP_14 + ), + IKEProposal( + name='IKE Proposal 2', + authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS, + encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC, + authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1, + group=DHGroupChoices.GROUP_14 + ), + ) + IKEProposal.objects.bulk_create(ike_proposals) + + ike_policies = ( + IKEPolicy( + name='IKE Policy 1', + version=IKEVersionChoices.VERSION_1, + mode=IKEModeChoices.MAIN, + ), + IKEPolicy( + name='IKE Policy 2', + version=IKEVersionChoices.VERSION_1, + mode=IKEModeChoices.MAIN, + ), + IKEPolicy( + name='IKE Policy 3', + version=IKEVersionChoices.VERSION_1, + mode=IKEModeChoices.MAIN, + ), + ) + IKEPolicy.objects.bulk_create(ike_policies) + for ike_policy in ike_policies: + ike_policy.proposals.set(ike_proposals) + + cls.create_data = [ + { + 'name': 'IKE Policy 4', + 'version': IKEVersionChoices.VERSION_1, + 'mode': IKEModeChoices.MAIN, + 'proposals': [ike_proposals[0].pk, ike_proposals[1].pk], + }, + { + 'name': 'IKE Policy 5', + 'version': IKEVersionChoices.VERSION_1, + 'mode': IKEModeChoices.MAIN, + 'proposals': [ike_proposals[0].pk, ike_proposals[1].pk], + }, + { + 'name': 'IKE Policy 6', + 'version': IKEVersionChoices.VERSION_1, + 'mode': IKEModeChoices.MAIN, + 'proposals': [ike_proposals[0].pk, ike_proposals[1].pk], + }, + ] + + +class IPSecProposalTest(APIViewTestCases.APIViewTestCase): + model = IPSecProposal + brief_fields = ['display', 'id', 'name', 'url'] + bulk_update_data = { + 'encryption_algorithm': EncryptionAlgorithmChoices.ENCRYPTION_AES192_CBC, + 'authentication_algorithm': AuthenticationAlgorithmChoices.AUTH_HMAC_MD5, + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + + ipsec_proposals = ( + IPSecProposal( + name='IPSec Proposal 1', + encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC, + authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1 + ), + IPSecProposal( + name='IPSec Proposal 2', + encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC, + authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1 + ), + IPSecProposal( + name='IPSec Proposal 3', + encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC, + authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1 + ), + ) + IPSecProposal.objects.bulk_create(ipsec_proposals) + + cls.create_data = [ + { + 'name': 'IPSec Proposal 4', + 'encryption_algorithm': EncryptionAlgorithmChoices.ENCRYPTION_AES256_CBC, + 'authentication_algorithm': AuthenticationAlgorithmChoices.AUTH_HMAC_SHA256, + }, + { + 'name': 'IPSec Proposal 5', + 'encryption_algorithm': EncryptionAlgorithmChoices.ENCRYPTION_AES256_CBC, + 'authentication_algorithm': AuthenticationAlgorithmChoices.AUTH_HMAC_SHA256, + }, + { + 'name': 'IPSec Proposal 6', + 'encryption_algorithm': EncryptionAlgorithmChoices.ENCRYPTION_AES256_CBC, + 'authentication_algorithm': AuthenticationAlgorithmChoices.AUTH_HMAC_SHA256, + }, + ] + + +class IPSecPolicyTest(APIViewTestCases.APIViewTestCase): + model = IPSecPolicy + brief_fields = ['display', 'id', 'name', 'url'] + bulk_update_data = { + 'pfs_group': DHGroupChoices.GROUP_5, + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + + ipsec_proposals = ( + IPSecProposal( + name='IPSec Policy 1', + encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC, + authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1 + ), + IPSecProposal( + name='IPSec Proposal 2', + encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC, + authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1 + ), + ) + IPSecProposal.objects.bulk_create(ipsec_proposals) + + ipsec_policies = ( + IPSecPolicy( + name='IPSec Policy 1', + pfs_group=DHGroupChoices.GROUP_14 + ), + IPSecPolicy( + name='IPSec Policy 2', + pfs_group=DHGroupChoices.GROUP_14 + ), + IPSecPolicy( + name='IPSec Policy 3', + pfs_group=DHGroupChoices.GROUP_14 + ), + ) + IPSecPolicy.objects.bulk_create(ipsec_policies) + for ipsec_policy in ipsec_policies: + ipsec_policy.proposals.set(ipsec_proposals) + + cls.create_data = [ + { + 'name': 'IPSec Policy 4', + 'pfs_group': DHGroupChoices.GROUP_16, + 'proposals': [ipsec_proposals[0].pk, ipsec_proposals[1].pk], + }, + { + 'name': 'IPSec Policy 5', + 'pfs_group': DHGroupChoices.GROUP_16, + 'proposals': [ipsec_proposals[0].pk, ipsec_proposals[1].pk], + }, + { + 'name': 'IPSec Policy 6', + 'pfs_group': DHGroupChoices.GROUP_16, + 'proposals': [ipsec_proposals[0].pk, ipsec_proposals[1].pk], + }, + ] + + +class IPSecProfileTest(APIViewTestCases.APIViewTestCase): + model = IPSecProfile + brief_fields = ['display', 'id', 'name', 'url'] + + @classmethod + def setUpTestData(cls): + + ike_proposal = IKEProposal.objects.create( + name='IKE Proposal 1', + authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS, + encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC, + authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1, + group=DHGroupChoices.GROUP_14 + ) + + ipsec_proposal = IPSecProposal.objects.create( + name='IPSec Proposal 1', + encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC, + authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1 + ) + + ike_policies = ( + IKEPolicy( + name='IKE Policy 1', + version=IKEVersionChoices.VERSION_1, + mode=IKEModeChoices.MAIN, + ), + IKEPolicy( + name='IKE Policy 2', + version=IKEVersionChoices.VERSION_1, + mode=IKEModeChoices.MAIN, + ), + ) + IKEPolicy.objects.bulk_create(ike_policies) + for ike_policy in ike_policies: + ike_policy.proposals.add(ike_proposal) + + ipsec_policies = ( + IPSecPolicy( + name='IPSec Policy 1', + pfs_group=DHGroupChoices.GROUP_14 + ), + IPSecPolicy( + name='IPSec Policy 2', + pfs_group=DHGroupChoices.GROUP_14 + ), + ) + IPSecPolicy.objects.bulk_create(ipsec_policies) + for ipsec_policy in ipsec_policies: + ipsec_policy.proposals.add(ipsec_proposal) + + ipsec_profiles = ( + IPSecProfile( + name='IPSec Profile 1', + mode=IPSecModeChoices.ESP, + ike_policy=ike_policies[0], + ipsec_policy=ipsec_policies[0] + ), + IPSecProfile( + name='IPSec Profile 2', + mode=IPSecModeChoices.ESP, + ike_policy=ike_policies[0], + ipsec_policy=ipsec_policies[0] + ), + IPSecProfile( + name='IPSec Profile 3', + mode=IPSecModeChoices.ESP, + ike_policy=ike_policies[0], + ipsec_policy=ipsec_policies[0] + ), + ) + IPSecProfile.objects.bulk_create(ipsec_profiles) + + cls.create_data = [ + { + 'name': 'IPSec Profile 4', + 'mode': IPSecModeChoices.AH, + 'ike_policy': ike_policies[1].pk, + 'ipsec_policy': ipsec_policies[1].pk, + }, + ] + + cls.bulk_update_data = { + 'mode': IPSecModeChoices.AH, + 'ike_policy': ike_policies[1].pk, + 'ipsec_policy': ipsec_policies[1].pk, + 'description': 'New description', + } diff --git a/netbox/vpn/tests/test_filtersets.py b/netbox/vpn/tests/test_filtersets.py new file mode 100644 index 000000000..966717f4a --- /dev/null +++ b/netbox/vpn/tests/test_filtersets.py @@ -0,0 +1,592 @@ +from django.test import TestCase + +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, create_test_virtualmachine + + +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) + + virtual_machine = create_test_virtualmachine('Virtual Machine 1') + vm_interfaces = ( + VMInterface(virtual_machine=virtual_machine, name='Interface 1'), + VMInterface(virtual_machine=virtual_machine, name='Interface 2'), + VMInterface(virtual_machine=virtual_machine, name='Interface 3'), + ) + VMInterface.objects.bulk_create(vm_interfaces) + + ip_addresses = ( + IPAddress(address='192.168.0.1/32'), + IPAddress(address='192.168.0.2/32'), + IPAddress(address='192.168.0.3/32'), + IPAddress(address='192.168.0.4/32'), + IPAddress(address='192.168.0.5/32'), + IPAddress(address='192.168.0.6/32'), + ) + IPAddress.objects.bulk_create(ip_addresses) + + tunnels = ( + Tunnel( + name='Tunnel 1', + status=TunnelStatusChoices.STATUS_ACTIVE, + encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP + ), + Tunnel( + name='Tunnel 2', + status=TunnelStatusChoices.STATUS_ACTIVE, + encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP + ), + Tunnel( + name='Tunnel 3', + status=TunnelStatusChoices.STATUS_ACTIVE, + encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP + ), + ) + Tunnel.objects.bulk_create(tunnels) + + tunnel_terminations = ( + # Tunnel 1 + TunnelTermination( + tunnel=tunnels[0], + role=TunnelTerminationRoleChoices.ROLE_HUB, + termination=interfaces[0], + outside_ip=ip_addresses[0] + ), + TunnelTermination( + tunnel=tunnels[0], + role=TunnelTerminationRoleChoices.ROLE_SPOKE, + termination=vm_interfaces[0], + outside_ip=ip_addresses[1] + ), + # Tunnel 2 + TunnelTermination( + tunnel=tunnels[1], + role=TunnelTerminationRoleChoices.ROLE_HUB, + termination=interfaces[1], + outside_ip=ip_addresses[2] + ), + TunnelTermination( + tunnel=tunnels[1], + role=TunnelTerminationRoleChoices.ROLE_SPOKE, + termination=vm_interfaces[1], + outside_ip=ip_addresses[3] + ), + # Tunnel 3 + TunnelTermination( + tunnel=tunnels[2], + role=TunnelTerminationRoleChoices.ROLE_PEER, + termination=interfaces[2], + outside_ip=ip_addresses[4] + ), + TunnelTermination( + tunnel=tunnels[2], + role=TunnelTerminationRoleChoices.ROLE_PEER, + termination=vm_interfaces[2], + outside_ip=ip_addresses[5] + ), + ) + TunnelTermination.objects.bulk_create(tunnel_terminations) + + def test_tunnel(self): + tunnels = Tunnel.objects.all()[:2] + params = {'tunnel_id': [tunnels[0].pk, tunnels[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'tunnel': [tunnels[0].name, tunnels[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_role(self): + params = {'role': [TunnelTerminationRoleChoices.ROLE_HUB, TunnelTerminationRoleChoices.ROLE_SPOKE]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_termination_type(self): + params = {'termination_type': 'dcim.interface'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + params = {'termination_type': 'virtualization.vminterface'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + + def test_interface(self): + interfaces = Interface.objects.all()[:2] + params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'interface': [interfaces[0].name, interfaces[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_vminterface(self): + vm_interfaces = VMInterface.objects.all()[:2] + params = {'vminterface_id': [vm_interfaces[0].pk, vm_interfaces[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'vminterface': [vm_interfaces[0].name, vm_interfaces[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_outside_ip(self): + ip_addresses = IPAddress.objects.all()[:2] + params = {'outside_ip_id': [ip_addresses[0].pk, ip_addresses[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class IKEProposalTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = IKEProposal.objects.all() + filterset = IKEProposalFilterSet + + @classmethod + def setUpTestData(cls): + ike_proposals = ( + IKEProposal( + name='IKE Proposal 1', + authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS, + encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC, + authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1, + group=DHGroupChoices.GROUP_1, + sa_lifetime=1000 + ), + 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) diff --git a/netbox/vpn/tests/test_views.py b/netbox/vpn/tests/test_views.py new file mode 100644 index 000000000..433eca467 --- /dev/null +++ b/netbox/vpn/tests/test_views.py @@ -0,0 +1,508 @@ +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 + # TODO: Workaround for conflict between form field and GFK + validation_excluded_fields = ('termination',) + + @classmethod + def setUpTestData(cls): + device = create_test_device('Device 1') + interfaces = ( + Interface(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL), + Interface(device=device, name='Interface 2', type=InterfaceTypeChoices.TYPE_VIRTUAL), + Interface(device=device, name='Interface 3', type=InterfaceTypeChoices.TYPE_VIRTUAL), + Interface(device=device, name='Interface 4', type=InterfaceTypeChoices.TYPE_VIRTUAL), + Interface(device=device, name='Interface 5', type=InterfaceTypeChoices.TYPE_VIRTUAL), + Interface(device=device, name='Interface 6', type=InterfaceTypeChoices.TYPE_VIRTUAL), + Interface(device=device, name='Interface 7', type=InterfaceTypeChoices.TYPE_VIRTUAL), + ) + Interface.objects.bulk_create(interfaces) + + tunnel = Tunnel.objects.create( + name='Tunnel 1', + status=TunnelStatusChoices.STATUS_ACTIVE, + encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP + ) + + tunnel_terminations = ( + TunnelTermination( + tunnel=tunnel, + role=TunnelTerminationRoleChoices.ROLE_HUB, + termination=interfaces[0] + ), + TunnelTermination( + tunnel=tunnel, + role=TunnelTerminationRoleChoices.ROLE_SPOKE, + termination=interfaces[1] + ), + TunnelTermination( + tunnel=tunnel, + role=TunnelTerminationRoleChoices.ROLE_SPOKE, + termination=interfaces[2] + ), + ) + TunnelTermination.objects.bulk_create(tunnel_terminations) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'tunnel': tunnel.pk, + 'role': TunnelTerminationRoleChoices.ROLE_PEER, + 'type': TunnelTerminationTypeChoices.TYPE_DEVICE, + 'parent': device.pk, + 'termination': interfaces[6].pk, + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "tunnel,role,device,termination", + "Tunnel 1,peer,Device 1,Interface 4", + "Tunnel 1,peer,Device 1,Interface 5", + "Tunnel 1,peer,Device 1,Interface 6", + ) + + cls.csv_update_data = ( + "id,role", + f"{tunnel_terminations[0].pk},peer", + f"{tunnel_terminations[1].pk},peer", + f"{tunnel_terminations[2].pk},peer", + ) + + cls.bulk_edit_data = { + 'role': TunnelTerminationRoleChoices.ROLE_PEER, + } + + +class IKEProposalTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = IKEProposal + + @classmethod + def setUpTestData(cls): + + ike_proposals = ( + IKEProposal( + name='IKE Proposal 1', + authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS, + encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC, + authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1, + group=DHGroupChoices.GROUP_14 + ), + IKEProposal( + name='IKE Proposal 2', + authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS, + encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC, + authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1, + group=DHGroupChoices.GROUP_14 + ), + IKEProposal( + name='IKE Proposal 3', + authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS, + encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC, + authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1, + group=DHGroupChoices.GROUP_14 + ), + ) + IKEProposal.objects.bulk_create(ike_proposals) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'name': 'IKE Proposal X', + 'authentication_method': AuthenticationMethodChoices.CERTIFICATES, + 'encryption_algorithm': EncryptionAlgorithmChoices.ENCRYPTION_AES192_CBC, + 'authentication_algorithm': AuthenticationAlgorithmChoices.AUTH_HMAC_SHA256, + 'group': DHGroupChoices.GROUP_19, + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "name,authentication_method,encryption_algorithm,authentication_algorithm,group", + "IKE Proposal 4,preshared-keys,aes-128-cbc,hmac-sha1,14", + "IKE Proposal 5,preshared-keys,aes-128-cbc,hmac-sha1,14", + "IKE Proposal 6,preshared-keys,aes-128-cbc,hmac-sha1,14", + ) + + cls.csv_update_data = ( + "id,description", + f"{ike_proposals[0].pk},New description", + f"{ike_proposals[1].pk},New description", + f"{ike_proposals[2].pk},New description", + ) + + cls.bulk_edit_data = { + 'description': 'New description', + 'authentication_method': AuthenticationMethodChoices.CERTIFICATES, + 'encryption_algorithm': EncryptionAlgorithmChoices.ENCRYPTION_AES192_CBC, + 'authentication_algorithm': AuthenticationAlgorithmChoices.AUTH_HMAC_SHA256, + 'group': DHGroupChoices.GROUP_19 + } + + +class IKEPolicyTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = IKEPolicy + + @classmethod + def setUpTestData(cls): + + ike_proposals = ( + IKEProposal( + name='IKE Proposal 1', + authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS, + encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC, + authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1, + group=DHGroupChoices.GROUP_14 + ), + IKEProposal( + name='IKE Proposal 2', + authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS, + encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC, + authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1, + group=DHGroupChoices.GROUP_14 + ), + ) + IKEProposal.objects.bulk_create(ike_proposals) + + ike_policies = ( + IKEPolicy( + name='IKE Policy 1', + version=IKEVersionChoices.VERSION_1, + mode=IKEModeChoices.MAIN, + ), + IKEPolicy( + name='IKE Policy 2', + version=IKEVersionChoices.VERSION_1, + mode=IKEModeChoices.MAIN, + ), + IKEPolicy( + name='IKE Policy 3', + version=IKEVersionChoices.VERSION_1, + mode=IKEModeChoices.MAIN, + ), + ) + IKEPolicy.objects.bulk_create(ike_policies) + for ike_policy in ike_policies: + ike_policy.proposals.set(ike_proposals) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'name': 'IKE Policy X', + 'version': IKEVersionChoices.VERSION_2, + 'mode': IKEModeChoices.AGGRESSIVE, + 'proposals': [p.pk for p in ike_proposals], + 'tags': [t.pk for t in tags], + } + + ike_proposal_names = ','.join([p.name for p in ike_proposals]) + cls.csv_data = ( + "name,version,mode,proposals", + f"IKE Proposal 4,2,aggressive,\"{ike_proposal_names}\"", + f"IKE Proposal 5,2,aggressive,\"{ike_proposal_names}\"", + f"IKE Proposal 6,2,aggressive,\"{ike_proposal_names}\"", + ) + + cls.csv_update_data = ( + "id,description", + f"{ike_policies[0].pk},New description", + f"{ike_policies[1].pk},New description", + f"{ike_policies[2].pk},New description", + ) + + cls.bulk_edit_data = { + 'description': 'New description', + 'version': IKEVersionChoices.VERSION_2, + 'mode': IKEModeChoices.AGGRESSIVE, + } + + +class IPSecProposalTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = IPSecProposal + + @classmethod + def setUpTestData(cls): + + ipsec_proposals = ( + IPSecProposal( + name='IPSec Proposal 1', + encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC, + authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1, + ), + IPSecProposal( + name='IPSec Proposal 2', + encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC, + authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1, + ), + IPSecProposal( + name='IPSec Proposal 3', + encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC, + authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1, + ), + ) + IPSecProposal.objects.bulk_create(ipsec_proposals) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'name': 'IPSec Proposal X', + 'encryption_algorithm': EncryptionAlgorithmChoices.ENCRYPTION_AES192_CBC, + 'authentication_algorithm': AuthenticationAlgorithmChoices.AUTH_HMAC_SHA256, + 'sa_lifetime_seconds': 3600, + 'sa_lifetime_data': 1000000, + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "name,encryption_algorithm,authentication_algorithm,sa_lifetime_seconds,sa_lifetime_data", + "IKE Proposal 4,aes-128-cbc,hmac-sha1,3600,1000000", + "IKE Proposal 5,aes-128-cbc,hmac-sha1,3600,1000000", + "IKE Proposal 6,aes-128-cbc,hmac-sha1,3600,1000000", + ) + + cls.csv_update_data = ( + "id,description", + f"{ipsec_proposals[0].pk},New description", + f"{ipsec_proposals[1].pk},New description", + f"{ipsec_proposals[2].pk},New description", + ) + + cls.bulk_edit_data = { + 'description': 'New description', + 'encryption_algorithm': EncryptionAlgorithmChoices.ENCRYPTION_AES192_CBC, + 'authentication_algorithm': AuthenticationAlgorithmChoices.AUTH_HMAC_SHA256, + 'sa_lifetime_seconds': 3600, + 'sa_lifetime_data': 1000000, + } + + +class IPSecPolicyTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = IPSecPolicy + + @classmethod + def setUpTestData(cls): + + ipsec_proposals = ( + IPSecProposal( + name='IPSec Policy 1', + encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC, + authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1 + ), + IPSecProposal( + name='IPSec Proposal 2', + encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC, + authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1 + ), + ) + IPSecProposal.objects.bulk_create(ipsec_proposals) + + ipsec_policies = ( + IPSecPolicy( + name='IPSec Policy 1', + pfs_group=DHGroupChoices.GROUP_14 + ), + IPSecPolicy( + name='IPSec Policy 2', + pfs_group=DHGroupChoices.GROUP_14 + ), + IPSecPolicy( + name='IPSec Policy 3', + pfs_group=DHGroupChoices.GROUP_14 + ), + ) + IPSecPolicy.objects.bulk_create(ipsec_policies) + for ipsec_policy in ipsec_policies: + ipsec_policy.proposals.set(ipsec_proposals) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'name': 'IPSec Policy X', + 'pfs_group': DHGroupChoices.GROUP_5, + 'proposals': [p.pk for p in ipsec_proposals], + 'tags': [t.pk for t in tags], + } + + ipsec_proposal_names = ','.join([p.name for p in ipsec_proposals]) + cls.csv_data = ( + "name,pfs_group,proposals", + f"IKE Proposal 4,19,\"{ipsec_proposal_names}\"", + f"IKE Proposal 5,19,\"{ipsec_proposal_names}\"", + f"IKE Proposal 6,19,\"{ipsec_proposal_names}\"", + ) + + cls.csv_update_data = ( + "id,description", + f"{ipsec_policies[0].pk},New description", + f"{ipsec_policies[1].pk},New description", + f"{ipsec_policies[2].pk},New description", + ) + + cls.bulk_edit_data = { + 'description': 'New description', + 'pfs_group': DHGroupChoices.GROUP_5, + } + + +class IPSecProfileTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = IPSecProfile + + @classmethod + def setUpTestData(cls): + + ike_proposal = IKEProposal.objects.create( + name='IKE Proposal 1', + authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS, + encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC, + authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1, + group=DHGroupChoices.GROUP_14 + ) + + ipsec_proposal = IPSecProposal.objects.create( + name='IPSec Proposal 1', + encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC, + authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1 + ) + + ike_policies = ( + IKEPolicy( + name='IKE Policy 1', + version=IKEVersionChoices.VERSION_1, + mode=IKEModeChoices.MAIN, + ), + IKEPolicy( + name='IKE Policy 2', + version=IKEVersionChoices.VERSION_1, + mode=IKEModeChoices.MAIN, + ), + ) + IKEPolicy.objects.bulk_create(ike_policies) + for ike_policy in ike_policies: + ike_policy.proposals.add(ike_proposal) + + ipsec_policies = ( + IPSecPolicy( + name='IPSec Policy 1', + pfs_group=DHGroupChoices.GROUP_14 + ), + IPSecPolicy( + name='IPSec Policy 2', + pfs_group=DHGroupChoices.GROUP_14 + ), + ) + IPSecPolicy.objects.bulk_create(ipsec_policies) + for ipsec_policy in ipsec_policies: + ipsec_policy.proposals.add(ipsec_proposal) + + ipsec_profiles = ( + IPSecProfile( + name='IPSec Profile 1', + mode=IPSecModeChoices.ESP, + ike_policy=ike_policies[0], + ipsec_policy=ipsec_policies[0] + ), + IPSecProfile( + name='IPSec Profile 2', + mode=IPSecModeChoices.ESP, + ike_policy=ike_policies[0], + ipsec_policy=ipsec_policies[0] + ), + IPSecProfile( + name='IPSec Profile 3', + mode=IPSecModeChoices.ESP, + ike_policy=ike_policies[0], + ipsec_policy=ipsec_policies[0] + ), + ) + IPSecProfile.objects.bulk_create(ipsec_profiles) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'name': 'IPSec Profile X', + 'mode': IPSecModeChoices.AH, + 'ike_policy': ike_policies[1].pk, + 'ipsec_policy': ipsec_policies[1].pk, + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "name,mode,ike_policy,ipsec_policy", + f"IKE Proposal 4,ah,IKE Policy 2,IPSec Policy 2", + f"IKE Proposal 5,ah,IKE Policy 2,IPSec Policy 2", + f"IKE Proposal 6,ah,IKE Policy 2,IPSec Policy 2", + ) + + cls.csv_update_data = ( + "id,description", + f"{ipsec_profiles[0].pk},New description", + f"{ipsec_profiles[1].pk},New description", + f"{ipsec_profiles[2].pk},New description", + ) + + cls.bulk_edit_data = { + 'description': 'New description', + 'mode': IPSecModeChoices.AH, + 'ike_policy': ike_policies[1].pk, + 'ipsec_policy': ipsec_policies[1].pk, + } diff --git a/netbox/vpn/urls.py b/netbox/vpn/urls.py new file mode 100644 index 000000000..7fe548245 --- /dev/null +++ b/netbox/vpn/urls.py @@ -0,0 +1,65 @@ +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'))), + + # 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'), + 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 000000000..56eadc077 --- /dev/null +++ b/netbox/vpn/views.py @@ -0,0 +1,334 @@ +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 + + def dispatch(self, request, *args, **kwargs): + + # If creating a new Tunnel, use the creation form + if 'pk' not in kwargs: + self.form = forms.TunnelCreateForm + + return super().dispatch(request, *args, **kwargs) + + +@register_model_view(Tunnel, 'delete') +class TunnelDeleteView(generic.ObjectDeleteView): + queryset = Tunnel.objects.all() + + +class TunnelBulkImportView(generic.BulkImportView): + queryset = Tunnel.objects.all() + model_form = forms.TunnelImportForm + + +class TunnelBulkEditView(generic.BulkEditView): + queryset = Tunnel.objects.annotate( + count_terminations=count_related(TunnelTermination, 'tunnel') + ) + filterset = filtersets.TunnelFilterSet + table = tables.TunnelTable + form = forms.TunnelBulkEditForm + + +class TunnelBulkDeleteView(generic.BulkDeleteView): + queryset = Tunnel.objects.annotate( + count_terminations=count_related(TunnelTermination, 'tunnel') + ) + filterset = filtersets.TunnelFilterSet + table = tables.TunnelTable + + +# +# Tunnel terminations +# + +class TunnelTerminationListView(generic.ObjectListView): + queryset = TunnelTermination.objects.all() + filterset = filtersets.TunnelTerminationFilterSet + filterset_form = forms.TunnelTerminationFilterForm + table = tables.TunnelTerminationTable + + +@register_model_view(TunnelTermination) +class TunnelTerminationView(generic.ObjectView): + queryset = TunnelTermination.objects.all() + + +@register_model_view(TunnelTermination, 'edit') +class TunnelTerminationEditView(generic.ObjectEditView): + queryset = TunnelTermination.objects.all() + form = forms.TunnelTerminationForm + + +@register_model_view(TunnelTermination, 'delete') +class TunnelTerminationDeleteView(generic.ObjectDeleteView): + queryset = TunnelTermination.objects.all() + + +class TunnelTerminationBulkImportView(generic.BulkImportView): + queryset = TunnelTermination.objects.all() + model_form = forms.TunnelTerminationImportForm + + +class TunnelTerminationBulkEditView(generic.BulkEditView): + queryset = TunnelTermination.objects.all() + filterset = filtersets.TunnelTerminationFilterSet + table = tables.TunnelTerminationTable + form = forms.TunnelTerminationBulkEditForm + + +class TunnelTerminationBulkDeleteView(generic.BulkDeleteView): + queryset = TunnelTermination.objects.all() + filterset = filtersets.TunnelTerminationFilterSet + table = tables.TunnelTerminationTable + + +# +# IKE proposals +# + +class IKEProposalListView(generic.ObjectListView): + queryset = IKEProposal.objects.all() + filterset = filtersets.IKEProposalFilterSet + filterset_form = forms.IKEProposalFilterForm + table = tables.IKEProposalTable + + +@register_model_view(IKEProposal) +class IKEProposalView(generic.ObjectView): + queryset = IKEProposal.objects.all() + + +@register_model_view(IKEProposal, 'edit') +class IKEProposalEditView(generic.ObjectEditView): + queryset = IKEProposal.objects.all() + form = forms.IKEProposalForm + + +@register_model_view(IKEProposal, 'delete') +class IKEProposalDeleteView(generic.ObjectDeleteView): + queryset = IKEProposal.objects.all() + + +class IKEProposalBulkImportView(generic.BulkImportView): + queryset = IKEProposal.objects.all() + model_form = forms.IKEProposalImportForm + + +class IKEProposalBulkEditView(generic.BulkEditView): + queryset = IKEProposal.objects.all() + filterset = filtersets.IKEProposalFilterSet + table = tables.IKEProposalTable + form = forms.IKEProposalBulkEditForm + + +class IKEProposalBulkDeleteView(generic.BulkDeleteView): + queryset = IKEProposal.objects.all() + filterset = filtersets.IKEProposalFilterSet + table = tables.IKEProposalTable + + +# +# IKE policies +# + +class IKEPolicyListView(generic.ObjectListView): + queryset = IKEPolicy.objects.all() + filterset = filtersets.IKEPolicyFilterSet + filterset_form = forms.IKEPolicyFilterForm + table = tables.IKEPolicyTable + + +@register_model_view(IKEPolicy) +class IKEPolicyView(generic.ObjectView): + queryset = IKEPolicy.objects.all() + + +@register_model_view(IKEPolicy, 'edit') +class IKEPolicyEditView(generic.ObjectEditView): + queryset = IKEPolicy.objects.all() + form = forms.IKEPolicyForm + + +@register_model_view(IKEPolicy, 'delete') +class IKEPolicyDeleteView(generic.ObjectDeleteView): + queryset = IKEPolicy.objects.all() + + +class IKEPolicyBulkImportView(generic.BulkImportView): + queryset = IKEPolicy.objects.all() + model_form = forms.IKEPolicyImportForm + + +class IKEPolicyBulkEditView(generic.BulkEditView): + queryset = IKEPolicy.objects.all() + filterset = filtersets.IKEPolicyFilterSet + table = tables.IKEPolicyTable + form = forms.IKEPolicyBulkEditForm + + +class IKEPolicyBulkDeleteView(generic.BulkDeleteView): + queryset = IKEPolicy.objects.all() + filterset = filtersets.IKEPolicyFilterSet + table = tables.IKEPolicyTable + + +# +# IPSec proposals +# + +class IPSecProposalListView(generic.ObjectListView): + queryset = IPSecProposal.objects.all() + filterset = filtersets.IPSecProposalFilterSet + filterset_form = forms.IPSecProposalFilterForm + table = tables.IPSecProposalTable + + +@register_model_view(IPSecProposal) +class IPSecProposalView(generic.ObjectView): + queryset = IPSecProposal.objects.all() + + +@register_model_view(IPSecProposal, 'edit') +class IPSecProposalEditView(generic.ObjectEditView): + queryset = IPSecProposal.objects.all() + form = forms.IPSecProposalForm + + +@register_model_view(IPSecProposal, 'delete') +class IPSecProposalDeleteView(generic.ObjectDeleteView): + queryset = IPSecProposal.objects.all() + + +class IPSecProposalBulkImportView(generic.BulkImportView): + queryset = IPSecProposal.objects.all() + model_form = forms.IPSecProposalImportForm + + +class IPSecProposalBulkEditView(generic.BulkEditView): + queryset = IPSecProposal.objects.all() + filterset = filtersets.IPSecProposalFilterSet + table = tables.IPSecProposalTable + form = forms.IPSecProposalBulkEditForm + + +class IPSecProposalBulkDeleteView(generic.BulkDeleteView): + queryset = IPSecProposal.objects.all() + filterset = filtersets.IPSecProposalFilterSet + table = tables.IPSecProposalTable + + +# +# IPSec policies +# + +class IPSecPolicyListView(generic.ObjectListView): + queryset = IPSecPolicy.objects.all() + filterset = filtersets.IPSecPolicyFilterSet + filterset_form = forms.IPSecPolicyFilterForm + table = tables.IPSecPolicyTable + + +@register_model_view(IPSecPolicy) +class IPSecPolicyView(generic.ObjectView): + queryset = IPSecPolicy.objects.all() + + +@register_model_view(IPSecPolicy, 'edit') +class IPSecPolicyEditView(generic.ObjectEditView): + queryset = IPSecPolicy.objects.all() + form = forms.IPSecPolicyForm + + +@register_model_view(IPSecPolicy, 'delete') +class IPSecPolicyDeleteView(generic.ObjectDeleteView): + queryset = IPSecPolicy.objects.all() + + +class IPSecPolicyBulkImportView(generic.BulkImportView): + queryset = IPSecPolicy.objects.all() + model_form = forms.IPSecPolicyImportForm + + +class IPSecPolicyBulkEditView(generic.BulkEditView): + queryset = IPSecPolicy.objects.all() + filterset = filtersets.IPSecPolicyFilterSet + table = tables.IPSecPolicyTable + form = forms.IPSecPolicyBulkEditForm + + +class IPSecPolicyBulkDeleteView(generic.BulkDeleteView): + queryset = IPSecPolicy.objects.all() + filterset = filtersets.IPSecPolicyFilterSet + table = tables.IPSecPolicyTable + + +# +# IPSec profiles +# + +class IPSecProfileListView(generic.ObjectListView): + queryset = IPSecProfile.objects.all() + filterset = filtersets.IPSecProfileFilterSet + filterset_form = forms.IPSecProfileFilterForm + table = tables.IPSecProfileTable + + +@register_model_view(IPSecProfile) +class IPSecProfileView(generic.ObjectView): + queryset = IPSecProfile.objects.all() + + +@register_model_view(IPSecProfile, 'edit') +class IPSecProfileEditView(generic.ObjectEditView): + queryset = IPSecProfile.objects.all() + form = forms.IPSecProfileForm + + +@register_model_view(IPSecProfile, 'delete') +class IPSecProfileDeleteView(generic.ObjectDeleteView): + queryset = IPSecProfile.objects.all() + + +class IPSecProfileBulkImportView(generic.BulkImportView): + queryset = IPSecProfile.objects.all() + model_form = forms.IPSecProfileImportForm + + +class IPSecProfileBulkEditView(generic.BulkEditView): + queryset = IPSecProfile.objects.all() + filterset = filtersets.IPSecProfileFilterSet + table = tables.IPSecProfileTable + form = forms.IPSecProfileBulkEditForm + + +class IPSecProfileBulkDeleteView(generic.BulkDeleteView): + queryset = IPSecProfile.objects.all() + filterset = filtersets.IPSecProfileFilterSet + table = tables.IPSecProfileTable From e4989300de45e60843575c3d2be53fa7b2f975a2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 28 Nov 2023 13:11:00 -0500 Subject: [PATCH 04/22] Draft v3.7 release notes --- docs/release-notes/version-3.7.md | 92 +++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 docs/release-notes/version-3.7.md diff --git a/docs/release-notes/version-3.7.md b/docs/release-notes/version-3.7.md new file mode 100644 index 000000000..292ed4eb0 --- /dev/null +++ b/docs/release-notes/version-3.7.md @@ -0,0 +1,92 @@ +## v3.7-beta1 (FUTURE) + +### Breaking Changes + +* The `ui_visibility` field on the [custom field model](../models/extras/customfield.md) has been replaced with two new fields: `ui_visible` and `ui_editable`. Existing values will be migrated automatically upon upgrade. +* The `FeatureQuery` class for querying content types by model feature has been removed. Plugins should now use the new `with_feature()` manager method on NetBox's proxy model for ContentType. +* The ConfigRevision model has been moved from `extras` to `core`. Configuration history will be retained throughout the upgrade process. +* The L2VPN and L2VPNTermination models have been moved from the `ipam` app to the new `vpn` app. All object data will be retained however please note that the relevant API endpoints have been moved to `/api/vpn/`. +* The `CustomFieldsMixin`, `SavedFiltersMixin`, and `TagsMixin` classes have moved from the `extras.forms.mixins` to `netbox.forms.mixins`. + +### New Features + +#### VPN Tunnels ([#9816](https://github.com/netbox-community/netbox/issues/9816)) + +Several new models have been introduced to enable [VPN tunnel management](../features/vpn-tunnels.md). Users can now define tunnels with two or more terminations to replicate peer-to-peer or hub-and-spoke topologies. Each termination is made to a virtual interface on a device or VM. Additionally, users can define IKE and IPSec policies which can be applied to tunnels to document encryption and authentication strategies. + +#### Virtual Machine Disks ([#8356](https://github.com/netbox-community/netbox/issues/8356)) + +A new [VirtualDisk](../models/virtualization/virtualdisk.md) model has been introduced to enable tracking the assignment of discrete virtual disks to virtual machines. The original `size` field has been retained on the VirtualMachine model, and will be automatically updated with the aggregate size of all assigned virtual disks. (Users who opt to eschew the new model may continue using the VirtualMachine `size` attribute as before.) + +#### Protection Rules ([#10244](https://github.com/netbox-community/netbox/issues/10244)) + +A new [`PROTECTION_RULES`](../configuration/data-validation.md#protection_rules) configuration parameter is now available. Similar to how [custom validation rules](../customization/custom-validation.md) can be used to enforce certain values for object attributes, protection rules guard against the deletion of objects which do not meet specified criteria. This enables an administrator to prevent, for example, the deletion of a site which has a status of "active." + +#### Improved Custom Field Visibility Controls ([#13299](https://github.com/netbox-community/netbox/issues/13299)) + +The old `ui_visible` field on the custom field model](../models/extras/customfield.md) has been replaced by two new fields, `ui_visible` and `ui_editable`, which control how and whether a custom field is displayed when view and editing an object, respectively. Separating these two functions into discrete fields enables more control over how each custom field is presented to users. The values of these fields will be appropriately set automatically during the upgrade process depending on the value of the original field. + +#### Extend Display of Global Search Results ([#14134](https://github.com/netbox-community/netbox/issues/14134)) + +Global search results now include additional context about each object, such as a description, status, and/or related objects. The set of attributes to be displayed is specific to each object type, and is defined by setting `display_attrs` under the object's [SearchIndex class](../plugins/development/search.md#netbox.search.SearchIndex). + +#### Table Column Registration for Plugins ([#14173](https://github.com/netbox-community/netbox/issues/14173)) + +Plugins can now [register their own custom columns](../plugins/development/tables.md#extending-core-tables) for inclusion on core NetBox tables. For example, a plugin can register a new column on SiteTable using the new `register_table_column()` utility function, and it will become available for users to select for display. + +#### Data Backend Registration for Plugins ([#13381](https://github.com/netbox-community/netbox/issues/13381)) + +Plugins can now [register their own data backends](../plugins/development/data-backends.md) for use with [synchronized data sources](../features/synchronized-data.md). This enables plugins to introduce new backends in addition to the git, S3, and local path backends provided natively. + +### Enhancements + +* [#12135](https://github.com/netbox-community/netbox/issues/12135) - Avoid orphaned interfaces by preventing the deletion of interfaces which have children assigned +* [#12216](https://github.com/netbox-community/netbox/issues/12216) - Add a `color` field for circuit types +* [#13230](https://github.com/netbox-community/netbox/issues/13230) - Allow device types to be excluded from consideration when calculating a rack's utilization +* [#13334](https://github.com/netbox-community/netbox/issues/13334) - Added an `error` field to the Job model to record any errors associated with its execution +* [#13427](https://github.com/netbox-community/netbox/issues/13427) - Introduced a mechanism for omitting models from general-purpose lists of object types +* [#13690](https://github.com/netbox-community/netbox/issues/13690) - Display any dependent objects to be deleted prior to deleting an object via the web UI +* [#13794](https://github.com/netbox-community/netbox/issues/13794) - Any models with a relationship to Tenant are now included automatically in the list of related objects under the tenant view +* [#13808](https://github.com/netbox-community/netbox/issues/13808) - Added a `/render-config` REST API endpoint for virtual machines +* [#14035](https://github.com/netbox-community/netbox/issues/14035) - Order objects of equivalent weight by value in global search results to improve readability +* [#14156](https://github.com/netbox-community/netbox/issues/14156) - Enable custom fields for contact assignments + +### Other Changes + +* [#13550](https://github.com/netbox-community/netbox/issues/13550) - Optimized the format for declaring view actions under `ActionsMixin` (backward compatibility has been retained) +* [#13645](https://github.com/netbox-community/netbox/issues/13645) - Installation of the `sentry-sdk` Python library is now required only if Sentry reporting is enabled +* [#14036](https://github.com/netbox-community/netbox/issues/14036) - Move plugin resources from the `extras` app into `netbox` (backward compatibility has been retained) +* [#14153](https://github.com/netbox-community/netbox/issues/14153) - Replace `FeatureQuery` with new `with_feature()` method on ContentType manager +* [#14311](https://github.com/netbox-community/netbox/issues/14311) - Move the L2VPN models from the `ipam` app to the new `vpn` app +* [#14312](https://github.com/netbox-community/netbox/issues/14312) - Move the ConfigRevision model from the `extras` app to `core` +* [#14326](https://github.com/netbox-community/netbox/issues/14326) - Form feature mixin classes have been moved from the `extras` app to `netbox` + +### REST API Changes + +* Introduced the following endpoints: + * `/api/virtualization/virtual-disks/` + * `/api/vpn/ike-policies/` + * `/api/vpn/ike-proposals/` + * `/api/vpn/ipsec-policies/` + * `/api/vpn/ipsec-profiles/` + * `/api/vpn/ipsec-proposals/` + * `/api/vpn/tunnels/` + * `/api/vpn/tunnel-terminations/` +* The following endpoints have been moved: + * `/api/ipam/l2vpns/` -> `/api/vpn/l2vpns/` + * `/api/ipam/l2vpn-terminations/` -> `/api/vpn/l2vpn-terminations/` +* circuits.CircuitType + * Added the optional `color` choice field +* core.Job + * Added the read-only `error` character field +* dcim.DeviceType + * Added the `exclude_from_utilization` boolean field +* extras.CustomField + * Removed the `ui_visibility` field + * Added the `ui_visible` and `ui_editable` choice fields +* tenancy.ContactAssignment + * Added support for custom fields +* virtualization.VirtualDisk + * Added the read-only `virtual_disk_count` integer field +* virtualization.VirtualMachine + * Added the `/render-config` endpoint From 8e7146cd06711b519d9ddd5a18a5e374cd0db0f4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 28 Nov 2023 13:11:30 -0500 Subject: [PATCH 05/22] v3.7 documentation updates --- docs/models/virtualization/virtualdisk.md | 13 +++++++++++++ docs/plugins/development/models.md | 4 ++++ 2 files changed, 17 insertions(+) create mode 100644 docs/models/virtualization/virtualdisk.md diff --git a/docs/models/virtualization/virtualdisk.md b/docs/models/virtualization/virtualdisk.md new file mode 100644 index 000000000..9d256bb66 --- /dev/null +++ b/docs/models/virtualization/virtualdisk.md @@ -0,0 +1,13 @@ +# Virtual Disks + +A virtual disk is used to model discrete virtual hard disks assigned to [virtual machines](./virtualmachine.md). + +## Fields + +### Name + +A human-friendly name that is unique to the assigned virtual machine. + +### Size + +The allocated disk size, in gigabytes. diff --git a/docs/plugins/development/models.md b/docs/plugins/development/models.md index 8394813f8..46af135e1 100644 --- a/docs/plugins/development/models.md +++ b/docs/plugins/development/models.md @@ -60,6 +60,10 @@ class MyModel(NetBoxModel): This attribute specifies the URL at which the documentation for this model can be reached. By default, it will return `/static/docs/models///`. Plugin models can override this to return a custom URL. For example, you might direct the user to your plugin's documentation hosted on [ReadTheDocs](https://readthedocs.org/). +#### `_netbox_private` + +By default, any model introduced by a plugin will appear in the list of available object types e.g. when creating a custom field or certain dashboard widgets. If your model is intended only for "behind the scenes use" and should not be exposed to end users, set `_netbox_private` to True. This will omit it from the list of general-purpose object types. + ### Enabling Features Individually If you prefer instead to enable only a subset of these features for a plugin model, NetBox provides a discrete "mix-in" class for each feature. You can subclass each of these individually when defining your model. (Your model will also need to inherit from Django's built-in `Model` class.) From d2fea4edc4a095dec92344c645371516d7290218 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 28 Nov 2023 13:45:00 -0500 Subject: [PATCH 06/22] Closes #14311: Move L2VPN models from `ipam` to `vpn` (#14358) * Move L2VPN and L2VPNTermination models from ipam to vpn * Move L2VPN resources from ipam to vpn * Extend migration to update content types * Misc cleanup --- netbox/dcim/api/serializers.py | 6 +- netbox/dcim/filtersets.py | 3 +- netbox/dcim/forms/filtersets.py | 3 +- netbox/dcim/models/device_components.py | 2 +- netbox/dcim/tables/template_code.py | 4 +- netbox/ipam/api/nested_serializers.py | 28 --- netbox/ipam/api/serializers.py | 54 +----- netbox/ipam/api/urls.py | 2 - netbox/ipam/api/views.py | 13 -- netbox/ipam/choices.py | 49 ----- netbox/ipam/constants.py | 6 - netbox/ipam/filtersets.py | 181 +----------------- netbox/ipam/forms/bulk_edit.py | 31 --- netbox/ipam/forms/bulk_import.py | 92 --------- netbox/ipam/forms/filtersets.py | 95 +-------- netbox/ipam/forms/model_forms.py | 96 ---------- netbox/ipam/graphql/schema.py | 17 +- netbox/ipam/graphql/types.py | 19 -- netbox/ipam/migrations/0068_move_l2vpn.py | 64 +++++++ netbox/ipam/models/__init__.py | 22 --- netbox/ipam/models/vlans.py | 3 +- netbox/ipam/search.py | 14 +- netbox/ipam/tables/__init__.py | 1 - netbox/ipam/tests/test_api.py | 93 --------- netbox/ipam/tests/test_filtersets.py | 162 +--------------- netbox/ipam/tests/test_models.py | 80 +------- netbox/ipam/tests/test_views.py | 141 +------------- netbox/ipam/urls.py | 16 -- netbox/ipam/views.py | 113 +---------- netbox/netbox/navigation/menu.py | 4 +- netbox/templates/ipam/routetarget.html | 4 +- netbox/templates/{ipam => vpn}/l2vpn.html | 8 +- .../{ipam => vpn}/l2vpntermination.html | 2 +- .../{ipam => vpn}/l2vpntermination_edit.html | 0 netbox/virtualization/api/serializers.py | 5 +- netbox/virtualization/forms/filtersets.py | 3 +- .../virtualization/models/virtualmachines.py | 2 +- .../virtualization/tables/virtualmachines.py | 4 +- netbox/vpn/api/nested_serializers.py | 27 +++ netbox/vpn/api/serializers.py | 56 +++++- netbox/vpn/api/urls.py | 2 + netbox/vpn/api/views.py | 14 ++ netbox/vpn/choices.py | 53 +++++ netbox/vpn/constants.py | 7 + netbox/vpn/filtersets.py | 180 ++++++++++++++++- netbox/vpn/forms/bulk_edit.py | 31 +++ netbox/vpn/forms/bulk_import.py | 94 ++++++++- netbox/vpn/forms/filtersets.py | 99 +++++++++- netbox/vpn/forms/model_forms.py | 100 +++++++++- netbox/vpn/graphql/gfk_mixins.py | 30 +++ netbox/vpn/graphql/schema.py | 12 ++ netbox/vpn/graphql/types.py | 22 ++- netbox/vpn/migrations/0002_move_l2vpn.py | 73 +++++++ netbox/vpn/models/__init__.py | 1 + netbox/{ipam => vpn}/models/l2vpn.py | 14 +- netbox/vpn/search.py | 12 ++ netbox/vpn/tables/__init__.py | 3 + netbox/vpn/{tables.py => tables/crypto.py} | 81 -------- netbox/{ipam => vpn}/tables/l2vpn.py | 6 +- netbox/vpn/tables/tunnels.py | 87 +++++++++ netbox/vpn/tests/test_api.py | 94 +++++++++ netbox/vpn/tests/test_filtersets.py | 169 +++++++++++++++- netbox/vpn/tests/test_models.py | 79 ++++++++ netbox/vpn/tests/test_views.py | 142 +++++++++++++- netbox/vpn/urls.py | 16 ++ netbox/vpn/views.py | 111 +++++++++++ 66 files changed, 1616 insertions(+), 1441 deletions(-) create mode 100644 netbox/ipam/migrations/0068_move_l2vpn.py rename netbox/templates/{ipam => vpn}/l2vpn.html (85%) rename netbox/templates/{ipam => vpn}/l2vpntermination.html (96%) rename netbox/templates/{ipam => vpn}/l2vpntermination_edit.html (100%) create mode 100644 netbox/vpn/constants.py create mode 100644 netbox/vpn/graphql/gfk_mixins.py create mode 100644 netbox/vpn/migrations/0002_move_l2vpn.py rename netbox/{ipam => vpn}/models/l2vpn.py (93%) create mode 100644 netbox/vpn/tables/__init__.py rename netbox/vpn/{tables.py => tables/crypto.py} (65%) rename netbox/{ipam => vpn}/tables/l2vpn.py (96%) create mode 100644 netbox/vpn/tables/tunnels.py create mode 100644 netbox/vpn/tests/test_models.py diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 32dcdc5bb..09933f2de 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -2,8 +2,8 @@ import decimal from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext as _ -from drf_spectacular.utils import extend_schema_field from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from timezone_field.rest_framework import TimeZoneSerializerField @@ -12,8 +12,7 @@ from dcim.constants import * from dcim.models import * from extras.api.nested_serializers import NestedConfigTemplateSerializer from ipam.api.nested_serializers import ( - NestedASNSerializer, NestedIPAddressSerializer, NestedL2VPNTerminationSerializer, NestedVLANSerializer, - NestedVRFSerializer, + NestedASNSerializer, NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer, ) from ipam.models import ASN, VLAN from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField @@ -27,6 +26,7 @@ from tenancy.api.nested_serializers import NestedTenantSerializer from users.api.nested_serializers import NestedUserSerializer from utilities.api import get_serializer_for_model from virtualization.api.nested_serializers import NestedClusterSerializer +from vpn.api.nested_serializers import NestedL2VPNTerminationSerializer from wireless.api.nested_serializers import NestedWirelessLANSerializer, NestedWirelessLinkSerializer from wireless.choices import * from wireless.models import WirelessLAN diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index ffd3879a8..36540f3e3 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -5,7 +5,7 @@ from django.utils.translation import gettext as _ from extras.filtersets import LocalConfigContextFilterSet from extras.models import ConfigTemplate from ipam.filtersets import PrimaryIPFilterSet -from ipam.models import ASN, L2VPN, IPAddress, VRF +from ipam.models import ASN, IPAddress, VRF from netbox.filtersets import ( BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet, ) @@ -17,6 +17,7 @@ from utilities.filters import ( TreeNodeMultipleChoiceFilter, ) from virtualization.models import Cluster +from vpn.models import L2VPN from wireless.choices import WirelessRoleChoices, WirelessChannelChoices from .choices import * from .constants import * diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index d0d321187..1c8713a28 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -7,12 +7,13 @@ from dcim.constants import * from dcim.models import * from extras.forms import LocalConfigContextFilterForm from extras.models import ConfigTemplate -from ipam.models import ASN, L2VPN, VRF +from ipam.models import ASN, VRF from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import ContactModelFilterForm, TenancyFilterForm from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField from utilities.forms.widgets import APISelectMultiple, NumberWithOptions +from vpn.models import L2VPN from wireless.choices import * __all__ = ( diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 705af7637..94ae2d6a6 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -730,7 +730,7 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd related_query_name='interface' ) l2vpn_terminations = GenericRelation( - to='ipam.L2VPNTermination', + to='vpn.L2VPNTermination', content_type_field='assigned_object_type', object_id_field='assigned_object_id', related_query_name='interface', diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index a24f9ea6d..bf2ce9de4 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -316,8 +316,8 @@ INTERFACE_BUTTONS = """ {% if perms.dcim.add_interface %}
  • Child Interface
  • {% endif %} - {% if perms.ipam.add_l2vpntermination %} -
  • L2VPN Termination
  • + {% if perms.vpn.add_l2vpntermination %} +
  • L2VPN Termination
  • {% endif %} {% if perms.ipam.add_fhrpgroupassignment %}
  • Assign FHRP Group
  • diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index 9e150e2cb..17d8d74a7 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -2,7 +2,6 @@ from drf_spectacular.utils import extend_schema_serializer from rest_framework import serializers from ipam import models -from ipam.models.l2vpn import L2VPNTermination, L2VPN from netbox.api.serializers import WritableNestedSerializer from .field_serializers import IPAddressField @@ -14,8 +13,6 @@ __all__ = [ 'NestedFHRPGroupAssignmentSerializer', 'NestedIPAddressSerializer', 'NestedIPRangeSerializer', - 'NestedL2VPNSerializer', - 'NestedL2VPNTerminationSerializer', 'NestedPrefixSerializer', 'NestedRIRSerializer', 'NestedRoleSerializer', @@ -223,28 +220,3 @@ class NestedServiceSerializer(WritableNestedSerializer): class Meta: model = models.Service fields = ['id', 'url', 'display', 'name', 'protocol', 'ports'] - -# -# L2VPN -# - - -class NestedL2VPNSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:l2vpn-detail') - - class Meta: - model = L2VPN - fields = [ - 'id', 'url', 'display', 'identifier', 'name', 'slug', 'type' - ] - - -class NestedL2VPNTerminationSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:l2vpntermination-detail') - l2vpn = NestedL2VPNSerializer() - - class Meta: - model = L2VPNTermination - fields = [ - 'id', 'url', 'display', 'l2vpn' - ] diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 6882de56d..33aa55a93 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -12,8 +12,9 @@ from netbox.constants import NESTED_SERIALIZER_PREFIX from tenancy.api.nested_serializers import NestedTenantSerializer from utilities.api import get_serializer_for_model from virtualization.api.nested_serializers import NestedVirtualMachineSerializer -from .nested_serializers import * +from vpn.api.nested_serializers import NestedL2VPNTerminationSerializer from .field_serializers import IPAddressField, IPNetworkField +from .nested_serializers import * # @@ -479,54 +480,3 @@ class ServiceSerializer(NetBoxModelSerializer): 'id', 'url', 'display', 'device', 'virtual_machine', 'name', 'ports', 'protocol', 'ipaddresses', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] - -# -# L2VPN -# - - -class L2VPNSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:l2vpn-detail') - type = ChoiceField(choices=L2VPNTypeChoices, required=False) - import_targets = SerializedPKRelatedField( - queryset=RouteTarget.objects.all(), - serializer=NestedRouteTargetSerializer, - required=False, - many=True - ) - export_targets = SerializedPKRelatedField( - queryset=RouteTarget.objects.all(), - serializer=NestedRouteTargetSerializer, - required=False, - many=True - ) - tenant = NestedTenantSerializer(required=False, allow_null=True) - - class Meta: - model = L2VPN - fields = [ - 'id', 'url', 'display', 'identifier', 'name', 'slug', 'type', 'import_targets', 'export_targets', - 'description', 'comments', 'tenant', 'tags', 'custom_fields', 'created', 'last_updated' - ] - - -class L2VPNTerminationSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='ipam-api:l2vpntermination-detail') - l2vpn = NestedL2VPNSerializer() - assigned_object_type = ContentTypeField( - queryset=ContentType.objects.all() - ) - assigned_object = serializers.SerializerMethodField(read_only=True) - - class Meta: - model = L2VPNTermination - fields = [ - 'id', 'url', 'display', 'l2vpn', 'assigned_object_type', 'assigned_object_id', - 'assigned_object', 'tags', 'custom_fields', 'created', 'last_updated' - ] - - @extend_schema_field(serializers.JSONField(allow_null=True)) - def get_assigned_object(self, instance): - serializer = get_serializer_for_model(instance.assigned_object, prefix=NESTED_SERIALIZER_PREFIX) - context = {'request': self.context['request']} - return serializer(instance.assigned_object, context=context).data diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index 442fd2240..bae9d8048 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -23,8 +23,6 @@ router.register('vlan-groups', views.VLANGroupViewSet) router.register('vlans', views.VLANViewSet) router.register('service-templates', views.ServiceTemplateViewSet) router.register('services', views.ServiceViewSet) -router.register('l2vpns', views.L2VPNViewSet) -router.register('l2vpn-terminations', views.L2VPNTerminationViewSet) app_name = 'ipam-api' diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 662b393de..688fe42e2 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -14,7 +14,6 @@ from circuits.models import Provider from dcim.models import Site from ipam import filtersets from ipam.models import * -from ipam.models import L2VPN, L2VPNTermination from ipam.utils import get_next_available_prefix from netbox.api.viewsets import NetBoxModelViewSet from netbox.api.viewsets.mixins import ObjectValidationMixin @@ -178,18 +177,6 @@ class ServiceViewSet(NetBoxModelViewSet): filterset_class = filtersets.ServiceFilterSet -class L2VPNViewSet(NetBoxModelViewSet): - queryset = L2VPN.objects.prefetch_related('import_targets', 'export_targets', 'tenant', 'tags') - serializer_class = serializers.L2VPNSerializer - filterset_class = filtersets.L2VPNFilterSet - - -class L2VPNTerminationViewSet(NetBoxModelViewSet): - queryset = L2VPNTermination.objects.prefetch_related('assigned_object') - serializer_class = serializers.L2VPNTerminationSerializer - filterset_class = filtersets.L2VPNTerminationFilterSet - - # # Views # diff --git a/netbox/ipam/choices.py b/netbox/ipam/choices.py index 436cbd040..017fd0430 100644 --- a/netbox/ipam/choices.py +++ b/netbox/ipam/choices.py @@ -172,52 +172,3 @@ class ServiceProtocolChoices(ChoiceSet): (PROTOCOL_UDP, 'UDP'), (PROTOCOL_SCTP, 'SCTP'), ) - - -class L2VPNTypeChoices(ChoiceSet): - TYPE_VPLS = 'vpls' - TYPE_VPWS = 'vpws' - TYPE_EPL = 'epl' - TYPE_EVPL = 'evpl' - TYPE_EPLAN = 'ep-lan' - TYPE_EVPLAN = 'evp-lan' - TYPE_EPTREE = 'ep-tree' - TYPE_EVPTREE = 'evp-tree' - TYPE_VXLAN = 'vxlan' - TYPE_VXLAN_EVPN = 'vxlan-evpn' - TYPE_MPLS_EVPN = 'mpls-evpn' - TYPE_PBB_EVPN = 'pbb-evpn' - - CHOICES = ( - ('VPLS', ( - (TYPE_VPWS, 'VPWS'), - (TYPE_VPLS, 'VPLS'), - )), - ('VXLAN', ( - (TYPE_VXLAN, 'VXLAN'), - (TYPE_VXLAN_EVPN, 'VXLAN-EVPN'), - )), - ('L2VPN E-VPN', ( - (TYPE_MPLS_EVPN, 'MPLS EVPN'), - (TYPE_PBB_EVPN, 'PBB EVPN'), - )), - ('E-Line', ( - (TYPE_EPL, 'EPL'), - (TYPE_EVPL, 'EVPL'), - )), - ('E-LAN', ( - (TYPE_EPLAN, 'Ethernet Private LAN'), - (TYPE_EVPLAN, 'Ethernet Virtual Private LAN'), - )), - ('E-Tree', ( - (TYPE_EPTREE, 'Ethernet Private Tree'), - (TYPE_EVPTREE, 'Ethernet Virtual Private Tree'), - )), - ) - - P2P = ( - TYPE_VPWS, - TYPE_EPL, - TYPE_EPLAN, - TYPE_EPTREE - ) diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index f26fce2b5..6dffd3287 100644 --- a/netbox/ipam/constants.py +++ b/netbox/ipam/constants.py @@ -86,9 +86,3 @@ VLANGROUP_SCOPE_TYPES = ( # 16-bit port number SERVICE_PORT_MIN = 1 SERVICE_PORT_MAX = 65535 - -L2VPN_ASSIGNMENT_MODELS = Q( - Q(app_label='dcim', model='interface') | - Q(app_label='ipam', model='vlan') | - Q(app_label='virtualization', model='vminterface') -) diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index ba944e3ad..08d22dd23 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -4,8 +4,8 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db.models import Q from django.utils.translation import gettext as _ -from drf_spectacular.utils import extend_schema_field from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema_field from netaddr.core import AddrFormatError from dcim.models import Device, Interface, Region, Site, SiteGroup @@ -15,6 +15,7 @@ from utilities.filters import ( ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter, ) from virtualization.models import VirtualMachine, VMInterface +from vpn.models import L2VPN from .choices import * from .models import * @@ -26,8 +27,6 @@ __all__ = ( 'FHRPGroupFilterSet', 'IPAddressFilterSet', 'IPRangeFilterSet', - 'L2VPNFilterSet', - 'L2VPNTerminationFilterSet', 'PrefixFilterSet', 'PrimaryIPFilterSet', 'RIRFilterSet', @@ -1059,182 +1058,6 @@ class ServiceFilterSet(NetBoxModelFilterSet): return queryset.filter(qs_filter) -# -# L2VPN -# - -class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet): - type = django_filters.MultipleChoiceFilter( - choices=L2VPNTypeChoices, - null_value=None - ) - import_target_id = django_filters.ModelMultipleChoiceFilter( - field_name='import_targets', - queryset=RouteTarget.objects.all(), - label=_('Import target'), - ) - import_target = django_filters.ModelMultipleChoiceFilter( - field_name='import_targets__name', - queryset=RouteTarget.objects.all(), - to_field_name='name', - label=_('Import target (name)'), - ) - export_target_id = django_filters.ModelMultipleChoiceFilter( - field_name='export_targets', - queryset=RouteTarget.objects.all(), - label=_('Export target'), - ) - export_target = django_filters.ModelMultipleChoiceFilter( - field_name='export_targets__name', - queryset=RouteTarget.objects.all(), - to_field_name='name', - label=_('Export target (name)'), - ) - - class Meta: - model = L2VPN - fields = ['id', 'identifier', 'name', 'slug', 'type', 'description'] - - def search(self, queryset, name, value): - if not value.strip(): - return queryset - qs_filter = Q(name__icontains=value) | Q(description__icontains=value) - try: - qs_filter |= Q(identifier=int(value)) - except ValueError: - pass - return queryset.filter(qs_filter) - - -class L2VPNTerminationFilterSet(NetBoxModelFilterSet): - l2vpn_id = django_filters.ModelMultipleChoiceFilter( - queryset=L2VPN.objects.all(), - label=_('L2VPN (ID)'), - ) - l2vpn = django_filters.ModelMultipleChoiceFilter( - field_name='l2vpn__slug', - queryset=L2VPN.objects.all(), - to_field_name='slug', - label=_('L2VPN (slug)'), - ) - region = MultiValueCharFilter( - method='filter_region', - field_name='slug', - label=_('Region (slug)'), - ) - region_id = MultiValueNumberFilter( - method='filter_region', - field_name='pk', - label=_('Region (ID)'), - ) - site = MultiValueCharFilter( - method='filter_site', - field_name='slug', - label=_('Site (slug)'), - ) - site_id = MultiValueNumberFilter( - method='filter_site', - field_name='pk', - label=_('Site (ID)'), - ) - device = django_filters.ModelMultipleChoiceFilter( - field_name='interface__device__name', - queryset=Device.objects.all(), - to_field_name='name', - label=_('Device (name)'), - ) - device_id = django_filters.ModelMultipleChoiceFilter( - field_name='interface__device', - queryset=Device.objects.all(), - label=_('Device (ID)'), - ) - virtual_machine = django_filters.ModelMultipleChoiceFilter( - field_name='vminterface__virtual_machine__name', - queryset=VirtualMachine.objects.all(), - to_field_name='name', - label=_('Virtual machine (name)'), - ) - virtual_machine_id = django_filters.ModelMultipleChoiceFilter( - field_name='vminterface__virtual_machine', - queryset=VirtualMachine.objects.all(), - label=_('Virtual machine (ID)'), - ) - interface = django_filters.ModelMultipleChoiceFilter( - field_name='interface__name', - queryset=Interface.objects.all(), - to_field_name='name', - label=_('Interface (name)'), - ) - interface_id = django_filters.ModelMultipleChoiceFilter( - field_name='interface', - queryset=Interface.objects.all(), - label=_('Interface (ID)'), - ) - vminterface = django_filters.ModelMultipleChoiceFilter( - field_name='vminterface__name', - queryset=VMInterface.objects.all(), - to_field_name='name', - label=_('VM interface (name)'), - ) - vminterface_id = django_filters.ModelMultipleChoiceFilter( - field_name='vminterface', - queryset=VMInterface.objects.all(), - label=_('VM Interface (ID)'), - ) - vlan = django_filters.ModelMultipleChoiceFilter( - field_name='vlan__name', - queryset=VLAN.objects.all(), - to_field_name='name', - label=_('VLAN (name)'), - ) - vlan_vid = django_filters.NumberFilter( - field_name='vlan__vid', - label=_('VLAN number (1-4094)'), - ) - vlan_id = django_filters.ModelMultipleChoiceFilter( - field_name='vlan', - queryset=VLAN.objects.all(), - label=_('VLAN (ID)'), - ) - assigned_object_type = ContentTypeFilter() - - class Meta: - model = L2VPNTermination - fields = ('id', 'assigned_object_type_id') - - def search(self, queryset, name, value): - if not value.strip(): - return queryset - qs_filter = Q(l2vpn__name__icontains=value) - return queryset.filter(qs_filter) - - def filter_assigned_object(self, queryset, name, value): - qs = queryset.filter( - Q(**{'{}__in'.format(name): value}) - ) - return qs - - def filter_site(self, queryset, name, value): - qs = queryset.filter( - Q( - Q(**{'vlan__site__{}__in'.format(name): value}) | - Q(**{'interface__device__site__{}__in'.format(name): value}) | - Q(**{'vminterface__virtual_machine__site__{}__in'.format(name): value}) - ) - ) - return qs - - def filter_region(self, queryset, name, value): - qs = queryset.filter( - Q( - Q(**{'vlan__site__region__{}__in'.format(name): value}) | - Q(**{'interface__device__site__region__{}__in'.format(name): value}) | - Q(**{'vminterface__virtual_machine__site__region__{}__in'.format(name): value}) - ) - ) - return qs - - class PrimaryIPFilterSet(django_filters.FilterSet): """ An inheritable FilterSet for models which support primary IP assignment. diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index f0a8286fc..bf4825be9 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -23,8 +23,6 @@ __all__ = ( 'FHRPGroupBulkEditForm', 'IPAddressBulkEditForm', 'IPRangeBulkEditForm', - 'L2VPNBulkEditForm', - 'L2VPNTerminationBulkEditForm', 'PrefixBulkEditForm', 'RIRBulkEditForm', 'RoleBulkEditForm', @@ -596,32 +594,3 @@ class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm): class ServiceBulkEditForm(ServiceTemplateBulkEditForm): model = Service - - -class L2VPNBulkEditForm(NetBoxModelBulkEditForm): - type = forms.ChoiceField( - label=_('Type'), - choices=add_blank_choice(L2VPNTypeChoices), - required=False - ) - tenant = DynamicModelChoiceField( - label=_('Tenant'), - queryset=Tenant.objects.all(), - required=False - ) - description = forms.CharField( - label=_('Description'), - max_length=200, - required=False - ) - comments = CommentField() - - model = L2VPN - fieldsets = ( - (None, ('type', 'tenant', 'description')), - ) - nullable_fields = ('tenant', 'description', 'comments') - - -class L2VPNTerminationBulkEditForm(NetBoxModelBulkEditForm): - model = L2VPN diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index ed3ceec2b..0627a6765 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -1,6 +1,5 @@ from django import forms from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ from dcim.models import Device, Interface, Site @@ -21,8 +20,6 @@ __all__ = ( 'FHRPGroupImportForm', 'IPAddressImportForm', 'IPRangeImportForm', - 'L2VPNImportForm', - 'L2VPNTerminationImportForm', 'PrefixImportForm', 'RIRImportForm', 'RoleImportForm', @@ -529,92 +526,3 @@ class ServiceImportForm(NetBoxModelImportForm): ) return self.cleaned_data['ipaddresses'] - - -class L2VPNImportForm(NetBoxModelImportForm): - tenant = CSVModelChoiceField( - label=_('Tenant'), - queryset=Tenant.objects.all(), - required=False, - to_field_name='name', - ) - type = CSVChoiceField( - label=_('Type'), - choices=L2VPNTypeChoices, - help_text=_('L2VPN type') - ) - - class Meta: - model = L2VPN - fields = ('identifier', 'name', 'slug', 'tenant', 'type', 'description', - 'comments', 'tags') - - -class L2VPNTerminationImportForm(NetBoxModelImportForm): - l2vpn = CSVModelChoiceField( - queryset=L2VPN.objects.all(), - required=True, - to_field_name='name', - label=_('L2VPN'), - ) - device = CSVModelChoiceField( - label=_('Device'), - queryset=Device.objects.all(), - required=False, - to_field_name='name', - help_text=_('Parent device (for interface)') - ) - virtual_machine = CSVModelChoiceField( - label=_('Virtual machine'), - queryset=VirtualMachine.objects.all(), - required=False, - to_field_name='name', - help_text=_('Parent virtual machine (for interface)') - ) - interface = CSVModelChoiceField( - label=_('Interface'), - queryset=Interface.objects.none(), # Can also refer to VMInterface - required=False, - to_field_name='name', - help_text=_('Assigned interface (device or VM)') - ) - vlan = CSVModelChoiceField( - label=_('VLAN'), - queryset=VLAN.objects.all(), - required=False, - to_field_name='name', - help_text=_('Assigned VLAN') - ) - - class Meta: - model = L2VPNTermination - fields = ('l2vpn', 'device', 'virtual_machine', 'interface', 'vlan', 'tags') - - def __init__(self, data=None, *args, **kwargs): - super().__init__(data, *args, **kwargs) - - if data: - - # Limit interface queryset by device or VM - if data.get('device'): - self.fields['interface'].queryset = Interface.objects.filter( - **{f"device__{self.fields['device'].to_field_name}": data['device']} - ) - elif data.get('virtual_machine'): - self.fields['interface'].queryset = VMInterface.objects.filter( - **{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']} - ) - - def clean(self): - super().clean() - - if self.cleaned_data.get('device') and self.cleaned_data.get('virtual_machine'): - raise ValidationError(_('Cannot import device and VM interface terminations simultaneously.')) - if not self.instance and not (self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')): - raise ValidationError(_('Each termination must specify either an interface or a VLAN.')) - if self.cleaned_data.get('interface') and self.cleaned_data.get('vlan'): - raise ValidationError(_('Cannot assign both an interface and a VLAN.')) - - # if this is an update we might not have interface or vlan in the form data - if self.cleaned_data.get('interface') or self.cleaned_data.get('vlan'): - self.instance.assigned_object = self.cleaned_data.get('interface') or self.cleaned_data.get('vlan') diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index a8ca91901..c7dad372d 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -1,5 +1,4 @@ from django import forms -from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext_lazy as _ from dcim.models import Location, Rack, Region, Site, SiteGroup, Device @@ -9,10 +8,9 @@ from ipam.models import * from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import TenancyFilterForm from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, add_blank_choice -from utilities.forms.fields import ( - ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField, -) +from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField from virtualization.models import VirtualMachine +from vpn.models import L2VPN __all__ = ( 'AggregateFilterForm', @@ -21,8 +19,6 @@ __all__ = ( 'FHRPGroupFilterForm', 'IPAddressFilterForm', 'IPRangeFilterForm', - 'L2VPNFilterForm', - 'L2VPNTerminationFilterForm', 'PrefixFilterForm', 'RIRFilterForm', 'RoleFilterForm', @@ -539,90 +535,3 @@ class ServiceFilterForm(ServiceTemplateFilterForm): label=_('Virtual Machine'), ) tag = TagFilterField(model) - - -class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): - model = L2VPN - fieldsets = ( - (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('type', 'import_target_id', 'export_target_id')), - (_('Tenant'), ('tenant_group_id', 'tenant_id')), - ) - type = forms.ChoiceField( - label=_('Type'), - choices=add_blank_choice(L2VPNTypeChoices), - required=False - ) - import_target_id = DynamicModelMultipleChoiceField( - queryset=RouteTarget.objects.all(), - required=False, - label=_('Import targets') - ) - export_target_id = DynamicModelMultipleChoiceField( - queryset=RouteTarget.objects.all(), - required=False, - label=_('Export targets') - ) - tag = TagFilterField(model) - - -class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm): - model = L2VPNTermination - fieldsets = ( - (None, ('filter_id', 'l2vpn_id',)), - (_('Assigned Object'), ( - 'assigned_object_type_id', 'region_id', 'site_id', 'device_id', 'virtual_machine_id', 'vlan_id', - )), - ) - l2vpn_id = DynamicModelChoiceField( - queryset=L2VPN.objects.all(), - required=False, - label=_('L2VPN') - ) - assigned_object_type_id = ContentTypeMultipleChoiceField( - queryset=ContentType.objects.filter(L2VPN_ASSIGNMENT_MODELS), - required=False, - label=_('Assigned Object Type'), - limit_choices_to=L2VPN_ASSIGNMENT_MODELS - ) - region_id = DynamicModelMultipleChoiceField( - queryset=Region.objects.all(), - required=False, - label=_('Region') - ) - site_id = DynamicModelMultipleChoiceField( - queryset=Site.objects.all(), - required=False, - null_option='None', - query_params={ - 'region_id': '$region_id' - }, - label=_('Site') - ) - device_id = DynamicModelMultipleChoiceField( - queryset=Device.objects.all(), - required=False, - null_option='None', - query_params={ - 'site_id': '$site_id' - }, - label=_('Device') - ) - vlan_id = DynamicModelMultipleChoiceField( - queryset=VLAN.objects.all(), - required=False, - null_option='None', - query_params={ - 'site_id': '$site_id' - }, - label=_('VLAN') - ) - virtual_machine_id = DynamicModelMultipleChoiceField( - queryset=VirtualMachine.objects.all(), - required=False, - null_option='None', - query_params={ - 'site_id': '$site_id' - }, - label=_('Virtual Machine') - ) diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index dd9e6b3e4..6c445ef27 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -29,8 +29,6 @@ __all__ = ( 'IPAddressBulkAddForm', 'IPAddressForm', 'IPRangeForm', - 'L2VPNForm', - 'L2VPNTerminationForm', 'PrefixForm', 'RIRForm', 'RoleForm', @@ -754,97 +752,3 @@ class ServiceCreateForm(ServiceForm): self.cleaned_data['description'] = service_template.description elif not all(self.cleaned_data[f] for f in ('name', 'protocol', 'ports')): raise forms.ValidationError("Must specify name, protocol, and port(s) if not using a service template.") - - -# -# L2VPN -# - - -class L2VPNForm(TenancyForm, NetBoxModelForm): - slug = SlugField() - import_targets = DynamicModelMultipleChoiceField( - label=_('Import targets'), - queryset=RouteTarget.objects.all(), - required=False - ) - export_targets = DynamicModelMultipleChoiceField( - label=_('Export targets'), - queryset=RouteTarget.objects.all(), - required=False - ) - comments = CommentField() - - fieldsets = ( - (_('L2VPN'), ('name', 'slug', 'type', 'identifier', 'description', 'tags')), - (_('Route Targets'), ('import_targets', 'export_targets')), - (_('Tenancy'), ('tenant_group', 'tenant')), - ) - - class Meta: - model = L2VPN - fields = ( - 'name', 'slug', 'type', 'identifier', 'import_targets', 'export_targets', 'tenant', 'description', - 'comments', 'tags' - ) - - -class L2VPNTerminationForm(NetBoxModelForm): - l2vpn = DynamicModelChoiceField( - queryset=L2VPN.objects.all(), - required=True, - query_params={}, - label=_('L2VPN'), - fetch_trigger='open' - ) - vlan = DynamicModelChoiceField( - queryset=VLAN.objects.all(), - required=False, - selector=True, - label=_('VLAN') - ) - interface = DynamicModelChoiceField( - label=_('Interface'), - queryset=Interface.objects.all(), - required=False, - selector=True - ) - vminterface = DynamicModelChoiceField( - queryset=VMInterface.objects.all(), - required=False, - selector=True, - label=_('Interface') - ) - - class Meta: - model = L2VPNTermination - fields = ('l2vpn', ) - - def __init__(self, *args, **kwargs): - instance = kwargs.get('instance') - initial = kwargs.get('initial', {}).copy() - - if instance: - if type(instance.assigned_object) is Interface: - initial['interface'] = instance.assigned_object - elif type(instance.assigned_object) is VLAN: - initial['vlan'] = instance.assigned_object - elif type(instance.assigned_object) is VMInterface: - initial['vminterface'] = instance.assigned_object - kwargs['initial'] = initial - - super().__init__(*args, **kwargs) - - def clean(self): - super().clean() - - interface = self.cleaned_data.get('interface') - vminterface = self.cleaned_data.get('vminterface') - vlan = self.cleaned_data.get('vlan') - - if not (interface or vminterface or vlan): - raise ValidationError(_('A termination must specify an interface or VLAN.')) - if len([x for x in (interface, vminterface, vlan) if x]) > 1: - raise ValidationError(_('A termination can only have one terminating object (an interface or VLAN).')) - - self.instance.assigned_object = interface or vminterface or vlan diff --git a/netbox/ipam/graphql/schema.py b/netbox/ipam/graphql/schema.py index 596b5eb78..6627c540e 100644 --- a/netbox/ipam/graphql/schema.py +++ b/netbox/ipam/graphql/schema.py @@ -1,9 +1,8 @@ import graphene + from ipam import models -from utilities.graphql_optimizer import gql_query_optimizer - from netbox.graphql.fields import ObjectField, ObjectListField - +from utilities.graphql_optimizer import gql_query_optimizer from .types import * @@ -38,18 +37,6 @@ class IPAMQuery(graphene.ObjectType): def resolve_ip_range_list(root, info, **kwargs): return gql_query_optimizer(models.IPRange.objects.all(), info) - l2vpn = ObjectField(L2VPNType) - l2vpn_list = ObjectListField(L2VPNType) - - def resolve_l2vpn_list(root, info, **kwargs): - return gql_query_optimizer(models.L2VPN.objects.all(), info) - - l2vpn_termination = ObjectField(L2VPNTerminationType) - l2vpn_termination_list = ObjectListField(L2VPNTerminationType) - - def resolve_l2vpn_termination_list(root, info, **kwargs): - return gql_query_optimizer(models.L2VPNTermination.objects.all(), info) - prefix = ObjectField(PrefixType) prefix_list = ObjectListField(PrefixType) diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index 6e834512e..b4350f9f2 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -1,6 +1,5 @@ import graphene -from extras.graphql.mixins import ContactsMixin from ipam import filtersets, models from netbox.graphql.scalars import BigInt from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType @@ -13,8 +12,6 @@ __all__ = ( 'FHRPGroupAssignmentType', 'IPAddressType', 'IPRangeType', - 'L2VPNType', - 'L2VPNTerminationType', 'PrefixType', 'RIRType', 'RoleType', @@ -188,19 +185,3 @@ class VRFType(NetBoxObjectType): model = models.VRF fields = '__all__' filterset_class = filtersets.VRFFilterSet - - -class L2VPNType(ContactsMixin, NetBoxObjectType): - class Meta: - model = models.L2VPN - fields = '__all__' - filtersets_class = filtersets.L2VPNFilterSet - - -class L2VPNTerminationType(NetBoxObjectType): - assigned_object = graphene.Field('ipam.graphql.gfk_mixins.L2VPNAssignmentType') - - class Meta: - model = models.L2VPNTermination - exclude = ('assigned_object_type', 'assigned_object_id') - filtersets_class = filtersets.L2VPNTerminationFilterSet diff --git a/netbox/ipam/migrations/0068_move_l2vpn.py b/netbox/ipam/migrations/0068_move_l2vpn.py new file mode 100644 index 000000000..b1a059de1 --- /dev/null +++ b/netbox/ipam/migrations/0068_move_l2vpn.py @@ -0,0 +1,64 @@ +from django.db import migrations + + +def update_content_types(apps, schema_editor): + ContentType = apps.get_model('contenttypes', 'ContentType') + + # Delete the new ContentTypes effected by the new models in the vpn app + ContentType.objects.filter(app_label='vpn', model='l2vpn').delete() + ContentType.objects.filter(app_label='vpn', model='l2vpntermination').delete() + + # Update the app labels of the original ContentTypes for ipam.L2VPN and ipam.L2VPNTermination to ensure + # that any foreign key references are preserved + ContentType.objects.filter(app_label='ipam', model='l2vpn').update(app_label='vpn') + ContentType.objects.filter(app_label='ipam', model='l2vpntermination').update(app_label='vpn') + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0067_ipaddress_index_host'), + ] + + operations = [ + migrations.RemoveConstraint( + model_name='l2vpntermination', + name='ipam_l2vpntermination_assigned_object', + ), + migrations.SeparateDatabaseAndState( + state_operations=[ + migrations.RemoveField( + model_name='l2vpntermination', + name='assigned_object_type', + ), + migrations.RemoveField( + model_name='l2vpntermination', + name='l2vpn', + ), + migrations.RemoveField( + model_name='l2vpntermination', + name='tags', + ), + migrations.DeleteModel( + name='L2VPN', + ), + migrations.DeleteModel( + name='L2VPNTermination', + ), + ], + database_operations=[ + migrations.AlterModelTable( + name='L2VPN', + table='vpn_l2vpn', + ), + migrations.AlterModelTable( + name='L2VPNTermination', + table='vpn_l2vpntermination', + ), + ], + ), + migrations.RunPython( + code=update_content_types, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/ipam/models/__init__.py b/netbox/ipam/models/__init__.py index a00919ee0..0d0b3d6ac 100644 --- a/netbox/ipam/models/__init__.py +++ b/netbox/ipam/models/__init__.py @@ -3,27 +3,5 @@ from .asns import * from .fhrp import * from .vrfs import * from .ip import * -from .l2vpn import * from .services import * from .vlans import * - -__all__ = ( - 'ASN', - 'ASNRange', - 'Aggregate', - 'IPAddress', - 'IPRange', - 'FHRPGroup', - 'FHRPGroupAssignment', - 'L2VPN', - 'L2VPNTermination', - 'Prefix', - 'RIR', - 'Role', - 'RouteTarget', - 'Service', - 'ServiceTemplate', - 'VLAN', - 'VLANGroup', - 'VRF', -) diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index b6aed5398..1327a6e9d 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -183,9 +183,8 @@ class VLAN(PrimaryModel): null=True, help_text=_("The primary function of this VLAN") ) - l2vpn_terminations = GenericRelation( - to='ipam.L2VPNTermination', + to='vpn.L2VPNTermination', content_type_field='assigned_object_type', object_id_field='assigned_object_id', related_query_name='vlan' diff --git a/netbox/ipam/search.py b/netbox/ipam/search.py index c08acce1b..a1cddbb1a 100644 --- a/netbox/ipam/search.py +++ b/netbox/ipam/search.py @@ -1,5 +1,5 @@ -from . import models from netbox.search import SearchIndex, register_search +from . import models @register_search @@ -69,18 +69,6 @@ class IPRangeIndex(SearchIndex): display_attrs = ('vrf', 'tenant', 'status', 'role', 'description') -@register_search -class L2VPNIndex(SearchIndex): - model = models.L2VPN - fields = ( - ('name', 100), - ('slug', 110), - ('description', 500), - ('comments', 5000), - ) - display_attrs = ('type', 'identifier', 'tenant', 'description') - - @register_search class PrefixIndex(SearchIndex): model = models.Prefix diff --git a/netbox/ipam/tables/__init__.py b/netbox/ipam/tables/__init__.py index 7d04a5fea..95676b82c 100644 --- a/netbox/ipam/tables/__init__.py +++ b/netbox/ipam/tables/__init__.py @@ -1,7 +1,6 @@ from .asn import * from .fhrp import * from .ip import * -from .l2vpn import * from .services import * from .vlans import * from .vrfs import * diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index d696c8dae..cb633e162 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -1100,96 +1100,3 @@ class ServiceTest(APIViewTestCases.APIViewTestCase): 'ports': [6], }, ] - - -class L2VPNTest(APIViewTestCases.APIViewTestCase): - model = L2VPN - brief_fields = ['display', 'id', 'identifier', 'name', 'slug', 'type', 'url'] - create_data = [ - { - 'name': 'L2VPN 4', - 'slug': 'l2vpn-4', - 'type': 'vxlan', - 'identifier': 33343344 - }, - { - 'name': 'L2VPN 5', - 'slug': 'l2vpn-5', - 'type': 'vxlan', - 'identifier': 33343345 - }, - { - 'name': 'L2VPN 6', - 'slug': 'l2vpn-6', - 'type': 'vpws', - 'identifier': 33343346 - }, - ] - bulk_update_data = { - 'description': 'New description', - } - - @classmethod - def setUpTestData(cls): - - l2vpns = ( - L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=650001), - L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=650002), - L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'), # No RD - ) - L2VPN.objects.bulk_create(l2vpns) - - -class L2VPNTerminationTest(APIViewTestCases.APIViewTestCase): - model = L2VPNTermination - brief_fields = ['display', 'id', 'l2vpn', 'url'] - - @classmethod - def setUpTestData(cls): - - vlans = ( - VLAN(name='VLAN 1', vid=651), - VLAN(name='VLAN 2', vid=652), - VLAN(name='VLAN 3', vid=653), - VLAN(name='VLAN 4', vid=654), - VLAN(name='VLAN 5', vid=655), - VLAN(name='VLAN 6', vid=656), - VLAN(name='VLAN 7', vid=657) - ) - VLAN.objects.bulk_create(vlans) - - l2vpns = ( - L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=650001), - L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=650002), - L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'), # No RD - ) - L2VPN.objects.bulk_create(l2vpns) - - l2vpnterminations = ( - L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]), - L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[1]), - L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[2]) - ) - L2VPNTermination.objects.bulk_create(l2vpnterminations) - - cls.create_data = [ - { - 'l2vpn': l2vpns[0].pk, - 'assigned_object_type': 'ipam.vlan', - 'assigned_object_id': vlans[3].pk, - }, - { - 'l2vpn': l2vpns[0].pk, - 'assigned_object_type': 'ipam.vlan', - 'assigned_object_id': vlans[4].pk, - }, - { - 'l2vpn': l2vpns[0].pk, - 'assigned_object_type': 'ipam.vlan', - 'assigned_object_id': vlans[5].pk, - }, - ] - - cls.bulk_update_data = { - 'l2vpn': l2vpns[2].pk - } diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 952376056..07f3e637f 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -7,9 +7,9 @@ from dcim.models import Device, DeviceRole, DeviceType, Interface, Location, Man from ipam.choices import * from ipam.filtersets import * from ipam.models import * +from tenancy.models import Tenant, TenantGroup from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface -from tenancy.models import Tenant, TenantGroup class ASNRangeTestCase(TestCase, ChangeLoggedFilterSetTests): @@ -1616,163 +1616,3 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) params = {'ipaddress': [str(ips[0].address), str(ips[1].address)]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - - -class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests): - queryset = L2VPN.objects.all() - filterset = L2VPNFilterSet - - @classmethod - def setUpTestData(cls): - - route_targets = ( - RouteTarget(name='1:1'), - RouteTarget(name='1:2'), - RouteTarget(name='1:3'), - RouteTarget(name='2:1'), - RouteTarget(name='2:2'), - RouteTarget(name='2:3'), - ) - RouteTarget.objects.bulk_create(route_targets) - - l2vpns = ( - L2VPN(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=65001), - L2VPN(name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VPWS, identifier=65002), - L2VPN(name='L2VPN 3', slug='l2vpn-3', type=L2VPNTypeChoices.TYPE_VPLS), - ) - L2VPN.objects.bulk_create(l2vpns) - l2vpns[0].import_targets.add(route_targets[0]) - l2vpns[1].import_targets.add(route_targets[1]) - l2vpns[2].import_targets.add(route_targets[2]) - l2vpns[0].export_targets.add(route_targets[3]) - l2vpns[1].export_targets.add(route_targets[4]) - l2vpns[2].export_targets.add(route_targets[5]) - - def test_name(self): - params = {'name': ['L2VPN 1', 'L2VPN 2']} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - - def test_slug(self): - params = {'slug': ['l2vpn-1', 'l2vpn-2']} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - - def test_identifier(self): - params = {'identifier': ['65001', '65002']} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - - def test_type(self): - params = {'type': [L2VPNTypeChoices.TYPE_VXLAN, L2VPNTypeChoices.TYPE_VPWS]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - - def test_import_targets(self): - route_targets = RouteTarget.objects.filter(name__in=['1:1', '1:2']) - params = {'import_target_id': [route_targets[0].pk, route_targets[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'import_target': [route_targets[0].name, route_targets[1].name]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - - def test_export_targets(self): - route_targets = RouteTarget.objects.filter(name__in=['2:1', '2:2']) - params = {'export_target_id': [route_targets[0].pk, route_targets[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'export_target': [route_targets[0].name, route_targets[1].name]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - - -class L2VPNTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): - queryset = L2VPNTermination.objects.all() - filterset = L2VPNTerminationFilterSet - - @classmethod - def setUpTestData(cls): - device = create_test_device('Device 1') - interfaces = ( - Interface(name='Interface 1', device=device, type=InterfaceTypeChoices.TYPE_1GE_FIXED), - Interface(name='Interface 2', device=device, type=InterfaceTypeChoices.TYPE_1GE_FIXED), - Interface(name='Interface 3', device=device, type=InterfaceTypeChoices.TYPE_1GE_FIXED), - ) - Interface.objects.bulk_create(interfaces) - - vm = create_test_virtualmachine('Virtual Machine 1') - vminterfaces = ( - VMInterface(name='Interface 1', virtual_machine=vm), - VMInterface(name='Interface 2', virtual_machine=vm), - VMInterface(name='Interface 3', virtual_machine=vm), - ) - VMInterface.objects.bulk_create(vminterfaces) - - vlans = ( - VLAN(name='VLAN 1', vid=101), - VLAN(name='VLAN 2', vid=102), - VLAN(name='VLAN 3', vid=103), - ) - VLAN.objects.bulk_create(vlans) - - l2vpns = ( - L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=65001), - L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=65002), - L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'), # No RD, - ) - L2VPN.objects.bulk_create(l2vpns) - - l2vpnterminations = ( - L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]), - L2VPNTermination(l2vpn=l2vpns[1], assigned_object=vlans[1]), - L2VPNTermination(l2vpn=l2vpns[2], assigned_object=vlans[2]), - L2VPNTermination(l2vpn=l2vpns[0], assigned_object=interfaces[0]), - L2VPNTermination(l2vpn=l2vpns[1], assigned_object=interfaces[1]), - L2VPNTermination(l2vpn=l2vpns[2], assigned_object=interfaces[2]), - L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vminterfaces[0]), - L2VPNTermination(l2vpn=l2vpns[1], assigned_object=vminterfaces[1]), - L2VPNTermination(l2vpn=l2vpns[2], assigned_object=vminterfaces[2]), - ) - L2VPNTermination.objects.bulk_create(l2vpnterminations) - - def test_l2vpn(self): - l2vpns = L2VPN.objects.all()[:2] - params = {'l2vpn_id': [l2vpns[0].pk, l2vpns[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) - params = {'l2vpn': [l2vpns[0].slug, l2vpns[1].slug]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) - - def test_content_type(self): - params = {'assigned_object_type_id': ContentType.objects.get(model='vlan').pk} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) - - def test_interface(self): - interfaces = Interface.objects.all()[:2] - params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - - def test_vminterface(self): - vminterfaces = VMInterface.objects.all()[:2] - params = {'vminterface_id': [vminterfaces[0].pk, vminterfaces[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - - def test_vlan(self): - vlans = VLAN.objects.all()[:2] - params = {'vlan_id': [vlans[0].pk, vlans[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'vlan': ['VLAN 1', 'VLAN 2']} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - - def test_site(self): - site = Site.objects.all().first() - params = {'site_id': [site.pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) - params = {'site': ['site-1']} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) - - def test_device(self): - device = Device.objects.all().first() - params = {'device_id': [device.pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) - params = {'device': ['Device 1']} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) - - def test_virtual_machine(self): - virtual_machine = VirtualMachine.objects.all().first() - params = {'virtual_machine_id': [virtual_machine.pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) - params = {'virtual_machine': ['Virtual Machine 1']} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) diff --git a/netbox/ipam/tests/test_models.py b/netbox/ipam/tests/test_models.py index 06cd9b445..5a37807a7 100644 --- a/netbox/ipam/tests/test_models.py +++ b/netbox/ipam/tests/test_models.py @@ -1,10 +1,9 @@ -from netaddr import IPNetwork, IPSet from django.core.exceptions import ValidationError from django.test import TestCase, override_settings +from netaddr import IPNetwork, IPSet -from dcim.models import Interface, Device, DeviceRole, DeviceType, Manufacturer, Site -from ipam.choices import IPAddressRoleChoices, PrefixStatusChoices -from ipam.models import Aggregate, IPAddress, IPRange, Prefix, RIR, VLAN, VLANGroup, VRF, L2VPN, L2VPNTermination +from ipam.choices import * +from ipam.models import * class TestAggregate(TestCase): @@ -539,76 +538,3 @@ class TestVLANGroup(TestCase): VLAN.objects.create(name='VLAN 104', vid=104, group=vlangroup) self.assertEqual(vlangroup.get_next_available_vid(), 105) - - -class TestL2VPNTermination(TestCase): - - @classmethod - def setUpTestData(cls): - - site = Site.objects.create(name='Site 1') - manufacturer = Manufacturer.objects.create(name='Manufacturer 1') - device_type = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer) - role = DeviceRole.objects.create(name='Switch') - device = Device.objects.create( - name='Device 1', - site=site, - device_type=device_type, - role=role, - status='active' - ) - - interfaces = ( - Interface(name='Interface 1', device=device, type='1000baset'), - Interface(name='Interface 2', device=device, type='1000baset'), - Interface(name='Interface 3', device=device, type='1000baset'), - Interface(name='Interface 4', device=device, type='1000baset'), - Interface(name='Interface 5', device=device, type='1000baset'), - ) - - Interface.objects.bulk_create(interfaces) - - vlans = ( - VLAN(name='VLAN 1', vid=651), - VLAN(name='VLAN 2', vid=652), - VLAN(name='VLAN 3', vid=653), - VLAN(name='VLAN 4', vid=654), - VLAN(name='VLAN 5', vid=655), - VLAN(name='VLAN 6', vid=656), - VLAN(name='VLAN 7', vid=657) - ) - - VLAN.objects.bulk_create(vlans) - - l2vpns = ( - L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=650001), - L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=650002), - L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'), # No RD - ) - L2VPN.objects.bulk_create(l2vpns) - - l2vpnterminations = ( - L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]), - L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[1]), - L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[2]) - ) - - L2VPNTermination.objects.bulk_create(l2vpnterminations) - - def test_duplicate_interface_terminations(self): - device = Device.objects.first() - interface = Interface.objects.filter(device=device).first() - l2vpn = L2VPN.objects.first() - - L2VPNTermination.objects.create(l2vpn=l2vpn, assigned_object=interface) - duplicate = L2VPNTermination(l2vpn=l2vpn, assigned_object=interface) - - self.assertRaises(ValidationError, duplicate.clean) - - def test_duplicate_vlan_terminations(self): - vlan = Interface.objects.first() - l2vpn = L2VPN.objects.first() - - L2VPNTermination.objects.create(l2vpn=l2vpn, assigned_object=vlan) - duplicate = L2VPNTermination(l2vpn=l2vpn, assigned_object=vlan) - self.assertRaises(ValidationError, duplicate.clean) diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index a37584f0f..bc42341ba 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -9,7 +9,7 @@ from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site, Inte from ipam.choices import * from ipam.models import * from tenancy.models import Tenant -from utilities.testing import ViewTestCases, create_test_device, create_tags +from utilities.testing import ViewTestCases, create_tags class ASNRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase): @@ -986,142 +986,3 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): self.assertEqual(instance.protocol, service_template.protocol) self.assertEqual(instance.ports, service_template.ports) self.assertEqual(instance.description, service_template.description) - - -class L2VPNTestCase(ViewTestCases.PrimaryObjectViewTestCase): - model = L2VPN - - @classmethod - def setUpTestData(cls): - rts = ( - RouteTarget(name='64534:123'), - RouteTarget(name='64534:321') - ) - RouteTarget.objects.bulk_create(rts) - - l2vpns = ( - L2VPN(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650001'), - L2VPN(name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650002'), - L2VPN(name='L2VPN 3', slug='l2vpn-3', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650003') - ) - L2VPN.objects.bulk_create(l2vpns) - - cls.csv_data = ( - 'name,slug,type,identifier', - 'L2VPN 5,l2vpn-5,vxlan,456', - 'L2VPN 6,l2vpn-6,vxlan,444', - ) - - cls.csv_update_data = ( - 'id,name,description', - f'{l2vpns[0].pk},L2VPN 7,New description 7', - f'{l2vpns[1].pk},L2VPN 8,New description 8', - ) - - cls.bulk_edit_data = { - 'description': 'New Description', - } - - cls.form_data = { - 'name': 'L2VPN 8', - 'slug': 'l2vpn-8', - 'type': L2VPNTypeChoices.TYPE_VXLAN, - 'identifier': 123, - 'description': 'Description', - 'import_targets': [rts[0].pk], - 'export_targets': [rts[1].pk] - } - - -class L2VPNTerminationTestCase( - ViewTestCases.GetObjectViewTestCase, - ViewTestCases.GetObjectChangelogViewTestCase, - ViewTestCases.CreateObjectViewTestCase, - ViewTestCases.EditObjectViewTestCase, - ViewTestCases.DeleteObjectViewTestCase, - ViewTestCases.ListObjectsViewTestCase, - ViewTestCases.BulkImportObjectsViewTestCase, - ViewTestCases.BulkDeleteObjectsViewTestCase, -): - - model = L2VPNTermination - - @classmethod - def setUpTestData(cls): - device = create_test_device('Device 1') - interface = Interface.objects.create(name='Interface 1', device=device, type='1000baset') - l2vpns = ( - L2VPN(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=650001), - L2VPN(name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=650002), - ) - L2VPN.objects.bulk_create(l2vpns) - - vlans = ( - VLAN(name='Vlan 1', vid=1001), - VLAN(name='Vlan 2', vid=1002), - VLAN(name='Vlan 3', vid=1003), - VLAN(name='Vlan 4', vid=1004), - VLAN(name='Vlan 5', vid=1005), - VLAN(name='Vlan 6', vid=1006) - ) - VLAN.objects.bulk_create(vlans) - - terminations = ( - L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]), - L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[1]), - L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[2]) - ) - L2VPNTermination.objects.bulk_create(terminations) - - cls.form_data = { - 'l2vpn': l2vpns[0].pk, - 'device': device.pk, - 'interface': interface.pk, - } - - cls.csv_data = ( - "l2vpn,vlan", - "L2VPN 1,Vlan 4", - "L2VPN 1,Vlan 5", - "L2VPN 1,Vlan 6", - ) - - cls.csv_update_data = ( - f"id,l2vpn", - f"{terminations[0].pk},{l2vpns[0].name}", - f"{terminations[1].pk},{l2vpns[0].name}", - f"{terminations[2].pk},{l2vpns[0].name}", - ) - - cls.bulk_edit_data = {} - - # TODO: Fix L2VPNTerminationImportForm validation to support bulk updates - def test_bulk_update_objects_with_permission(self): - pass - - # - # Custom assertions - # - - # TODO: Remove this - def assertInstanceEqual(self, instance, data, exclude=None, api=False): - """ - Override parent - """ - if exclude is None: - exclude = [] - - fields = [k for k in data.keys() if k not in exclude] - model_dict = self.model_to_dict(instance, fields=fields, api=api) - - # Omit any dictionary keys which are not instance attributes or have been excluded - relevant_data = { - k: v for k, v in data.items() if hasattr(instance, k) and k not in exclude - } - - # Handle relations on the model - for k, v in model_dict.items(): - if isinstance(v, object) and hasattr(v, 'first'): - model_dict[k] = v.first().pk - - self.assertDictEqual(model_dict, relevant_data) diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 3bfe34b7b..61deeff4b 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -131,20 +131,4 @@ urlpatterns = [ path('services/edit/', views.ServiceBulkEditView.as_view(), name='service_bulk_edit'), path('services/delete/', views.ServiceBulkDeleteView.as_view(), name='service_bulk_delete'), path('services//', include(get_model_urls('ipam', 'service'))), - - # L2VPN - path('l2vpns/', views.L2VPNListView.as_view(), name='l2vpn_list'), - path('l2vpns/add/', views.L2VPNEditView.as_view(), name='l2vpn_add'), - path('l2vpns/import/', views.L2VPNBulkImportView.as_view(), name='l2vpn_import'), - path('l2vpns/edit/', views.L2VPNBulkEditView.as_view(), name='l2vpn_bulk_edit'), - path('l2vpns/delete/', views.L2VPNBulkDeleteView.as_view(), name='l2vpn_bulk_delete'), - path('l2vpns//', include(get_model_urls('ipam', 'l2vpn'))), - - # L2VPN terminations - path('l2vpn-terminations/', views.L2VPNTerminationListView.as_view(), name='l2vpntermination_list'), - path('l2vpn-terminations/add/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_add'), - path('l2vpn-terminations/import/', views.L2VPNTerminationBulkImportView.as_view(), name='l2vpntermination_import'), - path('l2vpn-terminations/edit/', views.L2VPNTerminationBulkEditView.as_view(), name='l2vpntermination_bulk_edit'), - path('l2vpn-terminations/delete/', views.L2VPNTerminationBulkDeleteView.as_view(), name='l2vpntermination_bulk_delete'), - path('l2vpn-terminations//', include(get_model_urls('ipam', 'l2vpntermination'))), ] diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 48ea637d9..5c1ac6620 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -1,5 +1,5 @@ from django.contrib.contenttypes.models import ContentType -from django.db.models import F, Prefetch +from django.db.models import Prefetch from django.db.models.expressions import RawSQL from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse @@ -9,7 +9,6 @@ from circuits.models import Provider from dcim.filtersets import InterfaceFilterSet from dcim.models import Interface, Site from netbox.views import generic -from tenancy.views import ObjectContactsView from utilities.tables import get_table_ordering from utilities.utils import count_related from utilities.views import ViewTab, register_model_view @@ -19,7 +18,6 @@ from . import filtersets, forms, tables from .choices import PrefixStatusChoices from .constants import * from .models import * -from .tables.l2vpn import L2VPNTable, L2VPNTerminationTable from .utils import add_requested_prefixes, add_available_ipaddresses, add_available_vlans @@ -1243,112 +1241,3 @@ class ServiceBulkDeleteView(generic.BulkDeleteView): queryset = Service.objects.prefetch_related('device', 'virtual_machine') filterset = filtersets.ServiceFilterSet table = tables.ServiceTable - - -# L2VPN - -class L2VPNListView(generic.ObjectListView): - queryset = L2VPN.objects.all() - table = L2VPNTable - filterset = filtersets.L2VPNFilterSet - filterset_form = forms.L2VPNFilterForm - - -@register_model_view(L2VPN) -class L2VPNView(generic.ObjectView): - queryset = L2VPN.objects.all() - - def get_extra_context(self, request, instance): - import_targets_table = tables.RouteTargetTable( - instance.import_targets.prefetch_related('tenant'), - orderable=False - ) - export_targets_table = tables.RouteTargetTable( - instance.export_targets.prefetch_related('tenant'), - orderable=False - ) - - return { - 'import_targets_table': import_targets_table, - 'export_targets_table': export_targets_table, - } - - -@register_model_view(L2VPN, 'edit') -class L2VPNEditView(generic.ObjectEditView): - queryset = L2VPN.objects.all() - form = forms.L2VPNForm - - -@register_model_view(L2VPN, 'delete') -class L2VPNDeleteView(generic.ObjectDeleteView): - queryset = L2VPN.objects.all() - - -class L2VPNBulkImportView(generic.BulkImportView): - queryset = L2VPN.objects.all() - model_form = forms.L2VPNImportForm - - -class L2VPNBulkEditView(generic.BulkEditView): - queryset = L2VPN.objects.all() - filterset = filtersets.L2VPNFilterSet - table = tables.L2VPNTable - form = forms.L2VPNBulkEditForm - - -class L2VPNBulkDeleteView(generic.BulkDeleteView): - queryset = L2VPN.objects.all() - filterset = filtersets.L2VPNFilterSet - table = tables.L2VPNTable - - -@register_model_view(L2VPN, 'contacts') -class L2VPNContactsView(ObjectContactsView): - queryset = L2VPN.objects.all() - - -# -# L2VPN terminations -# - -class L2VPNTerminationListView(generic.ObjectListView): - queryset = L2VPNTermination.objects.all() - table = L2VPNTerminationTable - filterset = filtersets.L2VPNTerminationFilterSet - filterset_form = forms.L2VPNTerminationFilterForm - - -@register_model_view(L2VPNTermination) -class L2VPNTerminationView(generic.ObjectView): - queryset = L2VPNTermination.objects.all() - - -@register_model_view(L2VPNTermination, 'edit') -class L2VPNTerminationEditView(generic.ObjectEditView): - queryset = L2VPNTermination.objects.all() - form = forms.L2VPNTerminationForm - template_name = 'ipam/l2vpntermination_edit.html' - - -@register_model_view(L2VPNTermination, 'delete') -class L2VPNTerminationDeleteView(generic.ObjectDeleteView): - queryset = L2VPNTermination.objects.all() - - -class L2VPNTerminationBulkImportView(generic.BulkImportView): - queryset = L2VPNTermination.objects.all() - model_form = forms.L2VPNTerminationImportForm - - -class L2VPNTerminationBulkEditView(generic.BulkEditView): - queryset = L2VPNTermination.objects.all() - filterset = filtersets.L2VPNTerminationFilterSet - table = tables.L2VPNTerminationTable - form = forms.L2VPNTerminationBulkEditForm - - -class L2VPNTerminationBulkDeleteView(generic.BulkDeleteView): - queryset = L2VPNTermination.objects.all() - filterset = filtersets.L2VPNTerminationFilterSet - table = tables.L2VPNTerminationTable diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index e99b84b10..49aee3540 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -209,8 +209,8 @@ VPN_MENU = Menu( MenuGroup( label=_('L2VPNs'), items=( - get_model_item('ipam', 'l2vpn', _('L2VPNs')), - get_model_item('ipam', 'l2vpntermination', _('Terminations')), + get_model_item('vpn', 'l2vpn', _('L2VPNs')), + get_model_item('vpn', 'l2vpntermination', _('Terminations')), ), ), MenuGroup( diff --git a/netbox/templates/ipam/routetarget.html b/netbox/templates/ipam/routetarget.html index 497dc8a39..7894e946f 100644 --- a/netbox/templates/ipam/routetarget.html +++ b/netbox/templates/ipam/routetarget.html @@ -59,7 +59,7 @@
    {% trans "Importing L2VPNs" %}
    @@ -68,7 +68,7 @@
    {% trans "Exporting L2VPNs" %}
    diff --git a/netbox/templates/ipam/l2vpn.html b/netbox/templates/vpn/l2vpn.html similarity index 85% rename from netbox/templates/ipam/l2vpn.html rename to netbox/templates/vpn/l2vpn.html index af95aba9f..2176a537f 100644 --- a/netbox/templates/ipam/l2vpn.html +++ b/netbox/templates/vpn/l2vpn.html @@ -34,7 +34,7 @@
    - {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:l2vpn_list' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all url='vpn:l2vpn_list' %} {% plugin_left_page object %}
    @@ -56,12 +56,12 @@
    {% trans "Terminations" %}
    - {% if perms.ipam.add_l2vpntermination %} + {% if perms.vpn.add_l2vpntermination %} diff --git a/netbox/templates/ipam/l2vpntermination.html b/netbox/templates/vpn/l2vpntermination.html similarity index 96% rename from netbox/templates/ipam/l2vpntermination.html rename to netbox/templates/vpn/l2vpntermination.html index cc316bf39..0e7539481 100644 --- a/netbox/templates/ipam/l2vpntermination.html +++ b/netbox/templates/vpn/l2vpntermination.html @@ -25,7 +25,7 @@
    {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:l2vpntermination_list' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all url='vpn:l2vpntermination_list' %}
    diff --git a/netbox/templates/ipam/l2vpntermination_edit.html b/netbox/templates/vpn/l2vpntermination_edit.html similarity index 100% rename from netbox/templates/ipam/l2vpntermination_edit.html rename to netbox/templates/vpn/l2vpntermination_edit.html diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 95b2152a5..7ed36388b 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -6,15 +6,14 @@ from dcim.api.nested_serializers import ( ) from dcim.choices import InterfaceModeChoices from extras.api.nested_serializers import NestedConfigTemplateSerializer -from ipam.api.nested_serializers import ( - NestedIPAddressSerializer, NestedL2VPNTerminationSerializer, NestedVLANSerializer, NestedVRFSerializer, -) +from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer from ipam.models import VLAN from netbox.api.fields import ChoiceField, SerializedPKRelatedField from netbox.api.serializers import NetBoxModelSerializer from tenancy.api.nested_serializers import NestedTenantSerializer from virtualization.choices import * from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualDisk, VirtualMachine, VMInterface +from vpn.api.nested_serializers import NestedL2VPNTerminationSerializer from .nested_serializers import * diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py index 5eb3fea1c..ba0c4cc6d 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -4,13 +4,14 @@ from django.utils.translation import gettext_lazy as _ from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from extras.forms import LocalConfigContextFilterForm from extras.models import ConfigTemplate -from ipam.models import L2VPN, VRF +from ipam.models import VRF from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import ContactModelFilterForm, TenancyFilterForm from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES from utilities.forms.fields import DynamicModelMultipleChoiceField, TagFilterField from virtualization.choices import * from virtualization.models import * +from vpn.models import L2VPN __all__ = ( 'ClusterFilterForm', diff --git a/netbox/virtualization/models/virtualmachines.py b/netbox/virtualization/models/virtualmachines.py index 2126f2541..1824aae99 100644 --- a/netbox/virtualization/models/virtualmachines.py +++ b/netbox/virtualization/models/virtualmachines.py @@ -358,7 +358,7 @@ class VMInterface(ComponentModel, BaseInterface, TrackingModelMixin): related_query_name='vminterface', ) l2vpn_terminations = GenericRelation( - to='ipam.L2VPNTermination', + to='vpn.L2VPNTermination', content_type_field='assigned_object_type', object_id_field='assigned_object_id', related_query_name='vminterface', diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py index 1eeb06ea8..632e6878a 100644 --- a/netbox/virtualization/tables/virtualmachines.py +++ b/netbox/virtualization/tables/virtualmachines.py @@ -24,8 +24,8 @@ VMINTERFACE_BUTTONS = """ {% if perms.ipam.add_ipaddress %}
  • IP Address
  • {% endif %} - {% if perms.ipam.add_l2vpntermination %} -
  • L2VPN Termination
  • + {% if perms.vpn.add_l2vpntermination %} +
  • L2VPN Termination
  • {% endif %} {% if perms.ipam.add_fhrpgroupassignment %}
  • Assign FHRP Group
  • diff --git a/netbox/vpn/api/nested_serializers.py b/netbox/vpn/api/nested_serializers.py index c9c92d308..f2627869b 100644 --- a/netbox/vpn/api/nested_serializers.py +++ b/netbox/vpn/api/nested_serializers.py @@ -9,6 +9,8 @@ __all__ = ( 'NestedIPSecPolicySerializer', 'NestedIPSecProfileSerializer', 'NestedIPSecProposalSerializer', + 'NestedL2VPNSerializer', + 'NestedL2VPNTerminationSerializer', 'NestedTunnelSerializer', 'NestedTunnelTerminationSerializer', ) @@ -82,3 +84,28 @@ class NestedIPSecProfileSerializer(WritableNestedSerializer): class Meta: model = models.IPSecProfile fields = ('id', 'url', 'display', 'name') + + +# +# L2VPN +# + +class NestedL2VPNSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='vpn-api:l2vpn-detail') + + class Meta: + model = models.L2VPN + fields = [ + 'id', 'url', 'display', 'identifier', 'name', 'slug', 'type' + ] + + +class NestedL2VPNTerminationSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='vpn-api:l2vpntermination-detail') + l2vpn = NestedL2VPNSerializer() + + class Meta: + model = models.L2VPNTermination + fields = [ + 'id', 'url', 'display', 'l2vpn' + ] diff --git a/netbox/vpn/api/serializers.py b/netbox/vpn/api/serializers.py index 1a517fe59..cd464cf22 100644 --- a/netbox/vpn/api/serializers.py +++ b/netbox/vpn/api/serializers.py @@ -2,7 +2,8 @@ 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 ipam.api.nested_serializers import NestedIPAddressSerializer, NestedRouteTargetSerializer +from ipam.models import RouteTarget from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.serializers import NetBoxModelSerializer from netbox.constants import NESTED_SERIALIZER_PREFIX @@ -18,6 +19,8 @@ __all__ = ( 'IPSecPolicySerializer', 'IPSecProfileSerializer', 'IPSecProposalSerializer', + 'L2VPNSerializer', + 'L2VPNTerminationSerializer', 'TunnelSerializer', 'TunnelTerminationSerializer', ) @@ -191,3 +194,54 @@ class IPSecProfileSerializer(NetBoxModelSerializer): 'id', 'url', 'display', 'name', 'description', 'mode', 'ike_policy', 'ipsec_policy', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ) + + +# +# L2VPN +# + +class L2VPNSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='vpn-api:l2vpn-detail') + type = ChoiceField(choices=L2VPNTypeChoices, required=False) + import_targets = SerializedPKRelatedField( + queryset=RouteTarget.objects.all(), + serializer=NestedRouteTargetSerializer, + required=False, + many=True + ) + export_targets = SerializedPKRelatedField( + queryset=RouteTarget.objects.all(), + serializer=NestedRouteTargetSerializer, + required=False, + many=True + ) + tenant = NestedTenantSerializer(required=False, allow_null=True) + + class Meta: + model = L2VPN + fields = [ + 'id', 'url', 'display', 'identifier', 'name', 'slug', 'type', 'import_targets', 'export_targets', + 'description', 'comments', 'tenant', 'tags', 'custom_fields', 'created', 'last_updated' + ] + + +class L2VPNTerminationSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='vpn-api:l2vpntermination-detail') + l2vpn = NestedL2VPNSerializer() + assigned_object_type = ContentTypeField( + queryset=ContentType.objects.all() + ) + assigned_object = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = L2VPNTermination + fields = [ + 'id', 'url', 'display', 'l2vpn', 'assigned_object_type', 'assigned_object_id', + 'assigned_object', 'tags', 'custom_fields', 'created', 'last_updated' + ] + + @extend_schema_field(serializers.JSONField(allow_null=True)) + def get_assigned_object(self, instance): + serializer = get_serializer_for_model(instance.assigned_object, prefix=NESTED_SERIALIZER_PREFIX) + context = {'request': self.context['request']} + return serializer(instance.assigned_object, context=context).data diff --git a/netbox/vpn/api/urls.py b/netbox/vpn/api/urls.py index f646174d5..8938532dd 100644 --- a/netbox/vpn/api/urls.py +++ b/netbox/vpn/api/urls.py @@ -10,6 +10,8 @@ router.register('ipsec-proposals', views.IPSecProposalViewSet) router.register('ipsec-profiles', views.IPSecProfileViewSet) router.register('tunnels', views.TunnelViewSet) router.register('tunnel-terminations', views.TunnelTerminationViewSet) +router.register('l2vpns', views.L2VPNViewSet) +router.register('l2vpn-terminations', views.L2VPNTerminationViewSet) app_name = 'vpn-api' urlpatterns = router.urls diff --git a/netbox/vpn/api/views.py b/netbox/vpn/api/views.py index c0ccab7ab..9a691a171 100644 --- a/netbox/vpn/api/views.py +++ b/netbox/vpn/api/views.py @@ -12,6 +12,8 @@ __all__ = ( 'IPSecPolicyViewSet', 'IPSecProfileViewSet', 'IPSecProposalViewSet', + 'L2VPNViewSet', + 'L2VPNTerminationViewSet', 'TunnelTerminationViewSet', 'TunnelViewSet', 'VPNRootView', @@ -72,3 +74,15 @@ class IPSecProfileViewSet(NetBoxModelViewSet): queryset = IPSecProfile.objects.all() serializer_class = serializers.IPSecProfileSerializer filterset_class = filtersets.IPSecProfileFilterSet + + +class L2VPNViewSet(NetBoxModelViewSet): + queryset = L2VPN.objects.prefetch_related('import_targets', 'export_targets', 'tenant', 'tags') + serializer_class = serializers.L2VPNSerializer + filterset_class = filtersets.L2VPNFilterSet + + +class L2VPNTerminationViewSet(NetBoxModelViewSet): + queryset = L2VPNTermination.objects.prefetch_related('assigned_object') + serializer_class = serializers.L2VPNTerminationSerializer + filterset_class = filtersets.L2VPNTerminationFilterSet diff --git a/netbox/vpn/choices.py b/netbox/vpn/choices.py index a932c5055..a272060e9 100644 --- a/netbox/vpn/choices.py +++ b/netbox/vpn/choices.py @@ -199,3 +199,56 @@ class DHGroupChoices(ChoiceSet): (GROUP_33, _('Group {n}').format(n=33)), (GROUP_34, _('Group {n}').format(n=34)), ) + + +# +# L2VPN +# + +class L2VPNTypeChoices(ChoiceSet): + TYPE_VPLS = 'vpls' + TYPE_VPWS = 'vpws' + TYPE_EPL = 'epl' + TYPE_EVPL = 'evpl' + TYPE_EPLAN = 'ep-lan' + TYPE_EVPLAN = 'evp-lan' + TYPE_EPTREE = 'ep-tree' + TYPE_EVPTREE = 'evp-tree' + TYPE_VXLAN = 'vxlan' + TYPE_VXLAN_EVPN = 'vxlan-evpn' + TYPE_MPLS_EVPN = 'mpls-evpn' + TYPE_PBB_EVPN = 'pbb-evpn' + + CHOICES = ( + ('VPLS', ( + (TYPE_VPWS, 'VPWS'), + (TYPE_VPLS, 'VPLS'), + )), + ('VXLAN', ( + (TYPE_VXLAN, 'VXLAN'), + (TYPE_VXLAN_EVPN, 'VXLAN-EVPN'), + )), + ('L2VPN E-VPN', ( + (TYPE_MPLS_EVPN, 'MPLS EVPN'), + (TYPE_PBB_EVPN, 'PBB EVPN'), + )), + ('E-Line', ( + (TYPE_EPL, 'EPL'), + (TYPE_EVPL, 'EVPL'), + )), + ('E-LAN', ( + (TYPE_EPLAN, _('Ethernet Private LAN')), + (TYPE_EVPLAN, _('Ethernet Virtual Private LAN')), + )), + ('E-Tree', ( + (TYPE_EPTREE, _('Ethernet Private Tree')), + (TYPE_EVPTREE, _('Ethernet Virtual Private Tree')), + )), + ) + + P2P = ( + TYPE_VPWS, + TYPE_EPL, + TYPE_EPLAN, + TYPE_EPTREE + ) diff --git a/netbox/vpn/constants.py b/netbox/vpn/constants.py new file mode 100644 index 000000000..55e398dcd --- /dev/null +++ b/netbox/vpn/constants.py @@ -0,0 +1,7 @@ +from django.db.models import Q + +L2VPN_ASSIGNMENT_MODELS = Q( + Q(app_label='dcim', model='interface') | + Q(app_label='ipam', model='vlan') | + Q(app_label='virtualization', model='vminterface') +) diff --git a/netbox/vpn/filtersets.py b/netbox/vpn/filtersets.py index c0bd140c3..249de9ca2 100644 --- a/netbox/vpn/filtersets.py +++ b/netbox/vpn/filtersets.py @@ -2,12 +2,12 @@ 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 dcim.models import Device, Interface +from ipam.models import IPAddress, RouteTarget, VLAN from netbox.filtersets import NetBoxModelFilterSet from tenancy.filtersets import TenancyFilterSet from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter -from virtualization.models import VMInterface +from virtualization.models import VirtualMachine, VMInterface from .choices import * from .models import * @@ -17,6 +17,8 @@ __all__ = ( 'IPSecPolicyFilterSet', 'IPSecProfileFilterSet', 'IPSecProposalFilterSet', + 'L2VPNFilterSet', + 'L2VPNTerminationFilterSet', 'TunnelFilterSet', 'TunnelTerminationFilterSet', ) @@ -239,3 +241,175 @@ class IPSecProfileFilterSet(NetBoxModelFilterSet): Q(description__icontains=value) | Q(comments__icontains=value) ) + + +class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet): + type = django_filters.MultipleChoiceFilter( + choices=L2VPNTypeChoices, + null_value=None + ) + import_target_id = django_filters.ModelMultipleChoiceFilter( + field_name='import_targets', + queryset=RouteTarget.objects.all(), + label=_('Import target'), + ) + import_target = django_filters.ModelMultipleChoiceFilter( + field_name='import_targets__name', + queryset=RouteTarget.objects.all(), + to_field_name='name', + label=_('Import target (name)'), + ) + export_target_id = django_filters.ModelMultipleChoiceFilter( + field_name='export_targets', + queryset=RouteTarget.objects.all(), + label=_('Export target'), + ) + export_target = django_filters.ModelMultipleChoiceFilter( + field_name='export_targets__name', + queryset=RouteTarget.objects.all(), + to_field_name='name', + label=_('Export target (name)'), + ) + + class Meta: + model = L2VPN + fields = ['id', 'identifier', 'name', 'slug', 'type', 'description'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = Q(name__icontains=value) | Q(description__icontains=value) + try: + qs_filter |= Q(identifier=int(value)) + except ValueError: + pass + return queryset.filter(qs_filter) + + +class L2VPNTerminationFilterSet(NetBoxModelFilterSet): + l2vpn_id = django_filters.ModelMultipleChoiceFilter( + queryset=L2VPN.objects.all(), + label=_('L2VPN (ID)'), + ) + l2vpn = django_filters.ModelMultipleChoiceFilter( + field_name='l2vpn__slug', + queryset=L2VPN.objects.all(), + to_field_name='slug', + label=_('L2VPN (slug)'), + ) + region = MultiValueCharFilter( + method='filter_region', + field_name='slug', + label=_('Region (slug)'), + ) + region_id = MultiValueNumberFilter( + method='filter_region', + field_name='pk', + label=_('Region (ID)'), + ) + site = MultiValueCharFilter( + method='filter_site', + field_name='slug', + label=_('Site (slug)'), + ) + site_id = MultiValueNumberFilter( + method='filter_site', + field_name='pk', + label=_('Site (ID)'), + ) + device = django_filters.ModelMultipleChoiceFilter( + field_name='interface__device__name', + queryset=Device.objects.all(), + to_field_name='name', + label=_('Device (name)'), + ) + device_id = django_filters.ModelMultipleChoiceFilter( + field_name='interface__device', + queryset=Device.objects.all(), + label=_('Device (ID)'), + ) + virtual_machine = django_filters.ModelMultipleChoiceFilter( + field_name='vminterface__virtual_machine__name', + queryset=VirtualMachine.objects.all(), + to_field_name='name', + label=_('Virtual machine (name)'), + ) + virtual_machine_id = django_filters.ModelMultipleChoiceFilter( + field_name='vminterface__virtual_machine', + queryset=VirtualMachine.objects.all(), + label=_('Virtual machine (ID)'), + ) + interface = django_filters.ModelMultipleChoiceFilter( + field_name='interface__name', + queryset=Interface.objects.all(), + to_field_name='name', + label=_('Interface (name)'), + ) + interface_id = django_filters.ModelMultipleChoiceFilter( + field_name='interface', + queryset=Interface.objects.all(), + label=_('Interface (ID)'), + ) + vminterface = django_filters.ModelMultipleChoiceFilter( + field_name='vminterface__name', + queryset=VMInterface.objects.all(), + to_field_name='name', + label=_('VM interface (name)'), + ) + vminterface_id = django_filters.ModelMultipleChoiceFilter( + field_name='vminterface', + queryset=VMInterface.objects.all(), + label=_('VM Interface (ID)'), + ) + vlan = django_filters.ModelMultipleChoiceFilter( + field_name='vlan__name', + queryset=VLAN.objects.all(), + to_field_name='name', + label=_('VLAN (name)'), + ) + vlan_vid = django_filters.NumberFilter( + field_name='vlan__vid', + label=_('VLAN number (1-4094)'), + ) + vlan_id = django_filters.ModelMultipleChoiceFilter( + field_name='vlan', + queryset=VLAN.objects.all(), + label=_('VLAN (ID)'), + ) + assigned_object_type = ContentTypeFilter() + + class Meta: + model = L2VPNTermination + fields = ('id', 'assigned_object_type_id') + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = Q(l2vpn__name__icontains=value) + return queryset.filter(qs_filter) + + def filter_assigned_object(self, queryset, name, value): + qs = queryset.filter( + Q(**{'{}__in'.format(name): value}) + ) + return qs + + def filter_site(self, queryset, name, value): + qs = queryset.filter( + Q( + Q(**{'vlan__site__{}__in'.format(name): value}) | + Q(**{'interface__device__site__{}__in'.format(name): value}) | + Q(**{'vminterface__virtual_machine__site__{}__in'.format(name): value}) + ) + ) + return qs + + def filter_region(self, queryset, name, value): + qs = queryset.filter( + Q( + Q(**{'vlan__site__region__{}__in'.format(name): value}) | + Q(**{'interface__device__site__region__{}__in'.format(name): value}) | + Q(**{'vminterface__virtual_machine__site__region__{}__in'.format(name): value}) + ) + ) + return qs diff --git a/netbox/vpn/forms/bulk_edit.py b/netbox/vpn/forms/bulk_edit.py index a7b097b5c..4cbfd950d 100644 --- a/netbox/vpn/forms/bulk_edit.py +++ b/netbox/vpn/forms/bulk_edit.py @@ -14,6 +14,8 @@ __all__ = ( 'IPSecPolicyBulkEditForm', 'IPSecProfileBulkEditForm', 'IPSecProposalBulkEditForm', + 'L2VPNBulkEditForm', + 'L2VPNTerminationBulkEditForm', 'TunnelBulkEditForm', 'TunnelTerminationBulkEditForm', ) @@ -241,3 +243,32 @@ class IPSecProfileBulkEditForm(NetBoxModelBulkEditForm): nullable_fields = ( 'description', 'comments', ) + + +class L2VPNBulkEditForm(NetBoxModelBulkEditForm): + type = forms.ChoiceField( + label=_('Type'), + choices=add_blank_choice(L2VPNTypeChoices), + required=False + ) + tenant = DynamicModelChoiceField( + label=_('Tenant'), + queryset=Tenant.objects.all(), + required=False + ) + description = forms.CharField( + label=_('Description'), + max_length=200, + required=False + ) + comments = CommentField() + + model = L2VPN + fieldsets = ( + (None, ('type', 'tenant', 'description')), + ) + nullable_fields = ('tenant', 'description', 'comments') + + +class L2VPNTerminationBulkEditForm(NetBoxModelBulkEditForm): + model = L2VPN diff --git a/netbox/vpn/forms/bulk_import.py b/netbox/vpn/forms/bulk_import.py index 5b42cc761..33e93d28f 100644 --- a/netbox/vpn/forms/bulk_import.py +++ b/netbox/vpn/forms/bulk_import.py @@ -1,7 +1,8 @@ +from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ from dcim.models import Device, Interface -from ipam.models import IPAddress +from ipam.models import IPAddress, VLAN from netbox.forms import NetBoxModelImportForm from tenancy.models import Tenant from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField @@ -15,6 +16,8 @@ __all__ = ( 'IPSecPolicyImportForm', 'IPSecProfileImportForm', 'IPSecProposalImportForm', + 'L2VPNImportForm', + 'L2VPNTerminationImportForm', 'TunnelImportForm', 'TunnelTerminationImportForm', ) @@ -228,3 +231,92 @@ class IPSecProfileImportForm(NetBoxModelImportForm): fields = ( 'name', 'mode', 'ike_policy', 'ipsec_policy', 'description', 'comments', 'tags', ) + + +class L2VPNImportForm(NetBoxModelImportForm): + tenant = CSVModelChoiceField( + label=_('Tenant'), + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + ) + type = CSVChoiceField( + label=_('Type'), + choices=L2VPNTypeChoices, + help_text=_('L2VPN type') + ) + + class Meta: + model = L2VPN + fields = ('identifier', 'name', 'slug', 'tenant', 'type', 'description', + 'comments', 'tags') + + +class L2VPNTerminationImportForm(NetBoxModelImportForm): + l2vpn = CSVModelChoiceField( + queryset=L2VPN.objects.all(), + required=True, + to_field_name='name', + label=_('L2VPN'), + ) + device = CSVModelChoiceField( + label=_('Device'), + queryset=Device.objects.all(), + required=False, + to_field_name='name', + help_text=_('Parent device (for interface)') + ) + virtual_machine = CSVModelChoiceField( + label=_('Virtual machine'), + queryset=VirtualMachine.objects.all(), + required=False, + to_field_name='name', + help_text=_('Parent virtual machine (for interface)') + ) + interface = CSVModelChoiceField( + label=_('Interface'), + queryset=Interface.objects.none(), # Can also refer to VMInterface + required=False, + to_field_name='name', + help_text=_('Assigned interface (device or VM)') + ) + vlan = CSVModelChoiceField( + label=_('VLAN'), + queryset=VLAN.objects.all(), + required=False, + to_field_name='name', + help_text=_('Assigned VLAN') + ) + + class Meta: + model = L2VPNTermination + fields = ('l2vpn', 'device', 'virtual_machine', 'interface', 'vlan', 'tags') + + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) + + if data: + + # Limit interface queryset by device or VM + if data.get('device'): + self.fields['interface'].queryset = Interface.objects.filter( + **{f"device__{self.fields['device'].to_field_name}": data['device']} + ) + elif data.get('virtual_machine'): + self.fields['interface'].queryset = VMInterface.objects.filter( + **{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']} + ) + + def clean(self): + super().clean() + + if self.cleaned_data.get('device') and self.cleaned_data.get('virtual_machine'): + raise ValidationError(_('Cannot import device and VM interface terminations simultaneously.')) + if not self.instance and not (self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')): + raise ValidationError(_('Each termination must specify either an interface or a VLAN.')) + if self.cleaned_data.get('interface') and self.cleaned_data.get('vlan'): + raise ValidationError(_('Cannot assign both an interface and a VLAN.')) + + # if this is an update we might not have interface or vlan in the form data + if self.cleaned_data.get('interface') or self.cleaned_data.get('vlan'): + self.instance.assigned_object = self.cleaned_data.get('interface') or self.cleaned_data.get('vlan') diff --git a/netbox/vpn/forms/filtersets.py b/netbox/vpn/forms/filtersets.py index ec146919a..91ca8a8dc 100644 --- a/netbox/vpn/forms/filtersets.py +++ b/netbox/vpn/forms/filtersets.py @@ -1,10 +1,18 @@ from django import forms +from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext as _ +from dcim.models import Device, Region, Site +from ipam.models import RouteTarget, VLAN from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import TenancyFilterForm -from utilities.forms.fields import DynamicModelMultipleChoiceField, TagFilterField +from utilities.forms.fields import ( + ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField, +) +from utilities.forms.utils import add_blank_choice +from virtualization.models import VirtualMachine from vpn.choices import * +from vpn.constants import L2VPN_ASSIGNMENT_MODELS from vpn.models import * __all__ = ( @@ -13,6 +21,8 @@ __all__ = ( 'IPSecPolicyFilterForm', 'IPSecProfileFilterForm', 'IPSecProposalFilterForm', + 'L2VPNFilterForm', + 'L2VPNTerminationFilterForm', 'TunnelFilterForm', 'TunnelTerminationFilterForm', ) @@ -180,3 +190,90 @@ class IPSecProfileFilterForm(NetBoxModelFilterSetForm): label=_('IPSec policy') ) tag = TagFilterField(model) + + +class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): + model = L2VPN + fieldsets = ( + (None, ('q', 'filter_id', 'tag')), + (_('Attributes'), ('type', 'import_target_id', 'export_target_id')), + (_('Tenant'), ('tenant_group_id', 'tenant_id')), + ) + type = forms.ChoiceField( + label=_('Type'), + choices=add_blank_choice(L2VPNTypeChoices), + required=False + ) + import_target_id = DynamicModelMultipleChoiceField( + queryset=RouteTarget.objects.all(), + required=False, + label=_('Import targets') + ) + export_target_id = DynamicModelMultipleChoiceField( + queryset=RouteTarget.objects.all(), + required=False, + label=_('Export targets') + ) + tag = TagFilterField(model) + + +class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm): + model = L2VPNTermination + fieldsets = ( + (None, ('filter_id', 'l2vpn_id',)), + (_('Assigned Object'), ( + 'assigned_object_type_id', 'region_id', 'site_id', 'device_id', 'virtual_machine_id', 'vlan_id', + )), + ) + l2vpn_id = DynamicModelChoiceField( + queryset=L2VPN.objects.all(), + required=False, + label=_('L2VPN') + ) + assigned_object_type_id = ContentTypeMultipleChoiceField( + queryset=ContentType.objects.filter(L2VPN_ASSIGNMENT_MODELS), + required=False, + label=_('Assigned Object Type'), + limit_choices_to=L2VPN_ASSIGNMENT_MODELS + ) + region_id = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + required=False, + label=_('Region') + ) + site_id = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False, + null_option='None', + query_params={ + 'region_id': '$region_id' + }, + label=_('Site') + ) + device_id = DynamicModelMultipleChoiceField( + queryset=Device.objects.all(), + required=False, + null_option='None', + query_params={ + 'site_id': '$site_id' + }, + label=_('Device') + ) + vlan_id = DynamicModelMultipleChoiceField( + queryset=VLAN.objects.all(), + required=False, + null_option='None', + query_params={ + 'site_id': '$site_id' + }, + label=_('VLAN') + ) + virtual_machine_id = DynamicModelMultipleChoiceField( + queryset=VirtualMachine.objects.all(), + required=False, + null_option='None', + query_params={ + 'site_id': '$site_id' + }, + label=_('Virtual Machine') + ) diff --git a/netbox/vpn/forms/model_forms.py b/netbox/vpn/forms/model_forms.py index 35fa2cad3..e61993ddd 100644 --- a/netbox/vpn/forms/model_forms.py +++ b/netbox/vpn/forms/model_forms.py @@ -1,11 +1,12 @@ from django import forms +from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ from dcim.models import Device, Interface -from ipam.models import IPAddress +from ipam.models import IPAddress, RouteTarget, VLAN from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm -from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField +from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField from utilities.forms.utils import add_blank_choice from utilities.forms.widgets import HTMXSelect from virtualization.models import VirtualMachine, VMInterface @@ -18,6 +19,8 @@ __all__ = ( 'IPSecPolicyForm', 'IPSecProfileForm', 'IPSecProposalForm', + 'L2VPNForm', + 'L2VPNTerminationForm', 'TunnelCreateForm', 'TunnelForm', 'TunnelTerminationForm', @@ -355,3 +358,96 @@ class IPSecProfileForm(NetBoxModelForm): fields = [ 'name', 'description', 'mode', 'ike_policy', 'ipsec_policy', 'description', 'comments', 'tags', ] + + +# +# L2VPN +# + +class L2VPNForm(TenancyForm, NetBoxModelForm): + slug = SlugField() + import_targets = DynamicModelMultipleChoiceField( + label=_('Import targets'), + queryset=RouteTarget.objects.all(), + required=False + ) + export_targets = DynamicModelMultipleChoiceField( + label=_('Export targets'), + queryset=RouteTarget.objects.all(), + required=False + ) + comments = CommentField() + + fieldsets = ( + (_('L2VPN'), ('name', 'slug', 'type', 'identifier', 'description', 'tags')), + (_('Route Targets'), ('import_targets', 'export_targets')), + (_('Tenancy'), ('tenant_group', 'tenant')), + ) + + class Meta: + model = L2VPN + fields = ( + 'name', 'slug', 'type', 'identifier', 'import_targets', 'export_targets', 'tenant', 'description', + 'comments', 'tags' + ) + + +class L2VPNTerminationForm(NetBoxModelForm): + l2vpn = DynamicModelChoiceField( + queryset=L2VPN.objects.all(), + required=True, + query_params={}, + label=_('L2VPN'), + fetch_trigger='open' + ) + vlan = DynamicModelChoiceField( + queryset=VLAN.objects.all(), + required=False, + selector=True, + label=_('VLAN') + ) + interface = DynamicModelChoiceField( + label=_('Interface'), + queryset=Interface.objects.all(), + required=False, + selector=True + ) + vminterface = DynamicModelChoiceField( + queryset=VMInterface.objects.all(), + required=False, + selector=True, + label=_('Interface') + ) + + class Meta: + model = L2VPNTermination + fields = ('l2vpn', ) + + def __init__(self, *args, **kwargs): + instance = kwargs.get('instance') + initial = kwargs.get('initial', {}).copy() + + if instance: + if type(instance.assigned_object) is Interface: + initial['interface'] = instance.assigned_object + elif type(instance.assigned_object) is VLAN: + initial['vlan'] = instance.assigned_object + elif type(instance.assigned_object) is VMInterface: + initial['vminterface'] = instance.assigned_object + kwargs['initial'] = initial + + super().__init__(*args, **kwargs) + + def clean(self): + super().clean() + + interface = self.cleaned_data.get('interface') + vminterface = self.cleaned_data.get('vminterface') + vlan = self.cleaned_data.get('vlan') + + if not (interface or vminterface or vlan): + raise ValidationError(_('A termination must specify an interface or VLAN.')) + if len([x for x in (interface, vminterface, vlan) if x]) > 1: + raise ValidationError(_('A termination can only have one terminating object (an interface or VLAN).')) + + self.instance.assigned_object = interface or vminterface or vlan diff --git a/netbox/vpn/graphql/gfk_mixins.py b/netbox/vpn/graphql/gfk_mixins.py new file mode 100644 index 000000000..72272f7ad --- /dev/null +++ b/netbox/vpn/graphql/gfk_mixins.py @@ -0,0 +1,30 @@ +import graphene + +from dcim.graphql.types import InterfaceType +from dcim.models import Interface +from ipam.graphql.types import VLANType +from ipam.models import VLAN +from virtualization.graphql.types import VMInterfaceType +from virtualization.models import VMInterface + +__all__ = ( + 'L2VPNAssignmentType', +) + + +class L2VPNAssignmentType(graphene.Union): + class Meta: + types = ( + InterfaceType, + VLANType, + VMInterfaceType, + ) + + @classmethod + def resolve_type(cls, instance, info): + if type(instance) is Interface: + return InterfaceType + if type(instance) is VLAN: + return VLANType + if type(instance) is VMInterface: + return VMInterfaceType diff --git a/netbox/vpn/graphql/schema.py b/netbox/vpn/graphql/schema.py index 64e680882..9c8e1e502 100644 --- a/netbox/vpn/graphql/schema.py +++ b/netbox/vpn/graphql/schema.py @@ -38,6 +38,18 @@ class VPNQuery(graphene.ObjectType): def resolve_ipsec_proposal_list(root, info, **kwargs): return gql_query_optimizer(models.IPSecProposal.objects.all(), info) + l2vpn = ObjectField(L2VPNType) + l2vpn_list = ObjectListField(L2VPNType) + + def resolve_l2vpn_list(root, info, **kwargs): + return gql_query_optimizer(models.L2VPN.objects.all(), info) + + l2vpn_termination = ObjectField(L2VPNTerminationType) + l2vpn_termination_list = ObjectListField(L2VPNTerminationType) + + def resolve_l2vpn_termination_list(root, info, **kwargs): + return gql_query_optimizer(models.L2VPNTermination.objects.all(), info) + tunnel = ObjectField(TunnelType) tunnel_list = ObjectListField(TunnelType) diff --git a/netbox/vpn/graphql/types.py b/netbox/vpn/graphql/types.py index f46e8b697..840a44c7b 100644 --- a/netbox/vpn/graphql/types.py +++ b/netbox/vpn/graphql/types.py @@ -1,4 +1,6 @@ -from extras.graphql.mixins import CustomFieldsMixin, TagsMixin +import graphene + +from extras.graphql.mixins import ContactsMixin, CustomFieldsMixin, TagsMixin from netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObjectType from vpn import filtersets, models @@ -8,6 +10,8 @@ __all__ = ( 'IPSecPolicyType', 'IPSecProfileType', 'IPSecProposalType', + 'L2VPNType', + 'L2VPNTerminationType', 'TunnelTerminationType', 'TunnelType', ) @@ -67,3 +71,19 @@ class IPSecProfileType(OrganizationalObjectType): model = models.IPSecProfile fields = '__all__' filterset_class = filtersets.IPSecProfileFilterSet + + +class L2VPNType(ContactsMixin, NetBoxObjectType): + class Meta: + model = models.L2VPN + fields = '__all__' + filtersets_class = filtersets.L2VPNFilterSet + + +class L2VPNTerminationType(NetBoxObjectType): + assigned_object = graphene.Field('vpn.graphql.gfk_mixins.L2VPNAssignmentType') + + class Meta: + model = models.L2VPNTermination + exclude = ('assigned_object_type', 'assigned_object_id') + filtersets_class = filtersets.L2VPNTerminationFilterSet diff --git a/netbox/vpn/migrations/0002_move_l2vpn.py b/netbox/vpn/migrations/0002_move_l2vpn.py new file mode 100644 index 000000000..3ec49f830 --- /dev/null +++ b/netbox/vpn/migrations/0002_move_l2vpn.py @@ -0,0 +1,73 @@ +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers +import utilities.json + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0099_cachedvalue_ordering'), + ('contenttypes', '0002_remove_content_type_name'), + ('tenancy', '0012_contactassignment_custom_fields'), + ('ipam', '0068_move_l2vpn'), + ('vpn', '0001_initial'), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + state_operations=[ + migrations.CreateModel( + name='L2VPN', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)), + ('description', models.CharField(blank=True, max_length=200)), + ('comments', models.TextField(blank=True)), + ('name', models.CharField(max_length=100, unique=True)), + ('slug', models.SlugField(max_length=100, unique=True)), + ('type', models.CharField(max_length=50)), + ('identifier', models.BigIntegerField(blank=True, null=True)), + ('export_targets', models.ManyToManyField(blank=True, related_name='exporting_l2vpns', to='ipam.routetarget')), + ('import_targets', models.ManyToManyField(blank=True, related_name='importing_l2vpns', to='ipam.routetarget')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='l2vpns', to='tenancy.tenant')), + ], + options={ + 'verbose_name': 'L2VPN', + 'verbose_name_plural': 'L2VPNs', + 'ordering': ('name', 'identifier'), + }, + ), + migrations.CreateModel( + name='L2VPNTermination', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)), + ('assigned_object_id', models.PositiveBigIntegerField()), + ('assigned_object_type', models.ForeignKey(limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'dcim'), ('model', 'interface')), models.Q(('app_label', 'ipam'), ('model', 'vlan')), models.Q(('app_label', 'virtualization'), ('model', 'vminterface')), _connector='OR')), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')), + ('l2vpn', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='vpn.l2vpn')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'verbose_name': 'L2VPN termination', + 'verbose_name_plural': 'L2VPN terminations', + 'ordering': ('l2vpn',), + }, + ), + ], + # Tables have been renamed from ipam + database_operations=[], + ), + migrations.AddConstraint( + model_name='l2vpntermination', + constraint=models.UniqueConstraint( + fields=('assigned_object_type', 'assigned_object_id'), + name='vpn_l2vpntermination_assigned_object' + ), + ), + ] diff --git a/netbox/vpn/models/__init__.py b/netbox/vpn/models/__init__.py index 3b70eb418..2e76b980b 100644 --- a/netbox/vpn/models/__init__.py +++ b/netbox/vpn/models/__init__.py @@ -1,2 +1,3 @@ from .crypto import * +from .l2vpn import * from .tunnels import * diff --git a/netbox/ipam/models/l2vpn.py b/netbox/vpn/models/l2vpn.py similarity index 93% rename from netbox/ipam/models/l2vpn.py rename to netbox/vpn/models/l2vpn.py index a2742a8f3..f1a142283 100644 --- a/netbox/ipam/models/l2vpn.py +++ b/netbox/vpn/models/l2vpn.py @@ -6,10 +6,10 @@ from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ from core.models import ContentType -from ipam.choices import L2VPNTypeChoices -from ipam.constants import L2VPN_ASSIGNMENT_MODELS from netbox.models import NetBoxModel, PrimaryModel from netbox.models.features import ContactsMixin +from vpn.choices import L2VPNTypeChoices +from vpn.constants import L2VPN_ASSIGNMENT_MODELS __all__ = ( 'L2VPN', @@ -69,7 +69,7 @@ class L2VPN(ContactsMixin, PrimaryModel): return f'{self.name}' def get_absolute_url(self): - return reverse('ipam:l2vpn', args=[self.pk]) + return reverse('vpn:l2vpn', args=[self.pk]) @cached_property def can_add_termination(self): @@ -81,7 +81,7 @@ class L2VPN(ContactsMixin, PrimaryModel): class L2VPNTermination(NetBoxModel): l2vpn = models.ForeignKey( - to='ipam.L2VPN', + to='vpn.L2VPN', on_delete=models.CASCADE, related_name='terminations' ) @@ -99,7 +99,7 @@ class L2VPNTermination(NetBoxModel): clone_fields = ('l2vpn',) prerequisite_models = ( - 'ipam.L2VPN', + 'vpn.L2VPN', ) class Meta: @@ -107,7 +107,7 @@ class L2VPNTermination(NetBoxModel): constraints = ( models.UniqueConstraint( fields=('assigned_object_type', 'assigned_object_id'), - name='ipam_l2vpntermination_assigned_object' + name='vpn_l2vpntermination_assigned_object' ), ) verbose_name = _('L2VPN termination') @@ -119,7 +119,7 @@ class L2VPNTermination(NetBoxModel): return super().__str__() def get_absolute_url(self): - return reverse('ipam:l2vpntermination', args=[self.pk]) + return reverse('vpn:l2vpntermination', args=[self.pk]) def clean(self): # Only check is assigned_object is set. Required otherwise we have an Integrity Error thrown. diff --git a/netbox/vpn/search.py b/netbox/vpn/search.py index 70b0c644f..d0b2ad0c6 100644 --- a/netbox/vpn/search.py +++ b/netbox/vpn/search.py @@ -63,3 +63,15 @@ class IPSecProfileIndex(SearchIndex): ('comments', 5000), ) display_attrs = ('description',) + + +@register_search +class L2VPNIndex(SearchIndex): + model = models.L2VPN + fields = ( + ('name', 100), + ('slug', 110), + ('description', 500), + ('comments', 5000), + ) + display_attrs = ('type', 'identifier', 'tenant', 'description') diff --git a/netbox/vpn/tables/__init__.py b/netbox/vpn/tables/__init__.py new file mode 100644 index 000000000..2e76b980b --- /dev/null +++ b/netbox/vpn/tables/__init__.py @@ -0,0 +1,3 @@ +from .crypto import * +from .l2vpn import * +from .tunnels import * diff --git a/netbox/vpn/tables.py b/netbox/vpn/tables/crypto.py similarity index 65% rename from netbox/vpn/tables.py rename to netbox/vpn/tables/crypto.py index 304467586..cd6d3c24d 100644 --- a/netbox/vpn/tables.py +++ b/netbox/vpn/tables/crypto.py @@ -1,8 +1,6 @@ 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 * @@ -12,88 +10,9 @@ __all__ = ( 'IPSecPolicyTable', 'IPSecProposalTable', 'IPSecProfileTable', - 'TunnelTable', - 'TunnelTerminationTable', ) -class TunnelTable(TenancyColumnsMixin, NetBoxTable): - name = tables.Column( - verbose_name=_('Name'), - linkify=True - ) - status = columns.ChoiceFieldColumn( - verbose_name=_('Status') - ) - ipsec_profile = tables.Column( - verbose_name=_('IPSec profile'), - linkify=True - ) - terminations_count = columns.LinkedCountColumn( - accessor=Accessor('count_terminations'), - viewname='vpn:tunneltermination_list', - url_params={'tunnel_id': 'pk'}, - verbose_name=_('Terminations') - ) - comments = columns.MarkdownColumn( - verbose_name=_('Comments'), - ) - tags = columns.TagColumn( - url_name='vpn:tunnel_list' - ) - - class Meta(NetBoxTable.Meta): - model = Tunnel - fields = ( - 'pk', 'id', 'name', 'status', 'encapsulation', 'ipsec_profile', 'tenant', 'tenant_group', 'tunnel_id', - 'termination_count', 'description', 'comments', 'tags', 'created', 'last_updated', - ) - default_columns = ('pk', 'name', 'status', 'encapsulation', 'ipsec_profile', 'tenant', 'terminations_count') - - -class TunnelTerminationTable(TenancyColumnsMixin, NetBoxTable): - tunnel = tables.Column( - verbose_name=_('Tunnel'), - linkify=True - ) - role = columns.ChoiceFieldColumn( - verbose_name=_('Role') - ) - termination_parent = tables.Column( - accessor='termination__parent_object', - linkify=True, - orderable=False, - verbose_name=_('Host') - ) - termination = tables.Column( - verbose_name=_('Termination'), - linkify=True - ) - ip_addresses = tables.ManyToManyColumn( - accessor=tables.A('termination__ip_addresses'), - orderable=False, - linkify_item=True, - verbose_name=_('IP Addresses') - ) - outside_ip = tables.Column( - verbose_name=_('Outside IP'), - linkify=True - ) - tags = columns.TagColumn( - url_name='vpn:tunneltermination_list' - ) - - class Meta(NetBoxTable.Meta): - model = TunnelTermination - fields = ( - 'pk', 'id', 'tunnel', 'role', 'termination_parent', 'termination', 'ip_addresses', 'outside_ip', 'tags', - 'created', 'last_updated', - ) - default_columns = ( - 'pk', 'id', 'tunnel', 'role', 'termination_parent', 'termination', 'ip_addresses', 'outside_ip', - ) - - class IKEProposalTable(NetBoxTable): name = tables.Column( verbose_name=_('Name'), diff --git a/netbox/ipam/tables/l2vpn.py b/netbox/vpn/tables/l2vpn.py similarity index 96% rename from netbox/ipam/tables/l2vpn.py rename to netbox/vpn/tables/l2vpn.py index 8635ab62a..1f8b2c0d7 100644 --- a/netbox/ipam/tables/l2vpn.py +++ b/netbox/vpn/tables/l2vpn.py @@ -1,9 +1,9 @@ -from django.utils.translation import gettext_lazy as _ import django_tables2 as tables +from django.utils.translation import gettext_lazy as _ -from ipam.models import L2VPN, L2VPNTermination from netbox.tables import NetBoxTable, columns from tenancy.tables import TenancyColumnsMixin +from vpn.models import L2VPN, L2VPNTermination __all__ = ( 'L2VPNTable', @@ -37,7 +37,7 @@ class L2VPNTable(TenancyColumnsMixin, NetBoxTable): verbose_name=_('Comments'), ) tags = columns.TagColumn( - url_name='ipam:l2vpn_list' + url_name='vpn:l2vpn_list' ) class Meta(NetBoxTable.Meta): diff --git a/netbox/vpn/tables/tunnels.py b/netbox/vpn/tables/tunnels.py new file mode 100644 index 000000000..4023607ff --- /dev/null +++ b/netbox/vpn/tables/tunnels.py @@ -0,0 +1,87 @@ +import django_tables2 as tables +from django.utils.translation import gettext_lazy as _ +from django_tables2.utils import Accessor + +from netbox.tables import NetBoxTable, columns +from tenancy.tables import TenancyColumnsMixin +from vpn.models import * + +__all__ = ( + 'TunnelTable', + 'TunnelTerminationTable', +) + + +class TunnelTable(TenancyColumnsMixin, NetBoxTable): + name = tables.Column( + verbose_name=_('Name'), + linkify=True + ) + status = columns.ChoiceFieldColumn( + verbose_name=_('Status') + ) + ipsec_profile = tables.Column( + verbose_name=_('IPSec profile'), + linkify=True + ) + terminations_count = columns.LinkedCountColumn( + accessor=Accessor('count_terminations'), + viewname='vpn:tunneltermination_list', + url_params={'tunnel_id': 'pk'}, + verbose_name=_('Terminations') + ) + comments = columns.MarkdownColumn( + verbose_name=_('Comments'), + ) + tags = columns.TagColumn( + url_name='vpn:tunnel_list' + ) + + class Meta(NetBoxTable.Meta): + model = Tunnel + fields = ( + 'pk', 'id', 'name', 'status', 'encapsulation', 'ipsec_profile', 'tenant', 'tenant_group', 'tunnel_id', + 'termination_count', 'description', 'comments', 'tags', 'created', 'last_updated', + ) + default_columns = ('pk', 'name', 'status', 'encapsulation', 'tenant', 'terminations_count') + + +class TunnelTerminationTable(TenancyColumnsMixin, NetBoxTable): + tunnel = tables.Column( + verbose_name=_('Tunnel'), + linkify=True + ) + role = columns.ChoiceFieldColumn( + verbose_name=_('Role') + ) + 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 + ) + tags = columns.TagColumn( + url_name='vpn:tunneltermination_list' + ) + + class Meta(NetBoxTable.Meta): + model = TunnelTermination + fields = ( + 'pk', 'id', 'tunnel', 'role', 'interface_parent', 'interface', 'ip_addresses', 'outside_ip', 'tags', + 'created', 'last_updated', + ) + default_columns = ('pk', 'id', 'tunnel', 'role', 'interface_parent', 'interface', 'ip_addresses', 'outside_ip') diff --git a/netbox/vpn/tests/test_api.py b/netbox/vpn/tests/test_api.py index 9bfa297ab..2714bd4fc 100644 --- a/netbox/vpn/tests/test_api.py +++ b/netbox/vpn/tests/test_api.py @@ -2,6 +2,7 @@ from django.urls import reverse from dcim.choices import InterfaceTypeChoices from dcim.models import Interface +from ipam.models import VLAN from utilities.testing import APITestCase, APIViewTestCases, create_test_device from vpn.choices import * from vpn.models import * @@ -471,3 +472,96 @@ class IPSecProfileTest(APIViewTestCases.APIViewTestCase): 'ipsec_policy': ipsec_policies[1].pk, 'description': 'New description', } + + +class L2VPNTest(APIViewTestCases.APIViewTestCase): + model = L2VPN + brief_fields = ['display', 'id', 'identifier', 'name', 'slug', 'type', 'url'] + create_data = [ + { + 'name': 'L2VPN 4', + 'slug': 'l2vpn-4', + 'type': 'vxlan', + 'identifier': 33343344 + }, + { + 'name': 'L2VPN 5', + 'slug': 'l2vpn-5', + 'type': 'vxlan', + 'identifier': 33343345 + }, + { + 'name': 'L2VPN 6', + 'slug': 'l2vpn-6', + 'type': 'vpws', + 'identifier': 33343346 + }, + ] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + + l2vpns = ( + L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=650001), + L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=650002), + L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'), # No RD + ) + L2VPN.objects.bulk_create(l2vpns) + + +class L2VPNTerminationTest(APIViewTestCases.APIViewTestCase): + model = L2VPNTermination + brief_fields = ['display', 'id', 'l2vpn', 'url'] + + @classmethod + def setUpTestData(cls): + + vlans = ( + VLAN(name='VLAN 1', vid=651), + VLAN(name='VLAN 2', vid=652), + VLAN(name='VLAN 3', vid=653), + VLAN(name='VLAN 4', vid=654), + VLAN(name='VLAN 5', vid=655), + VLAN(name='VLAN 6', vid=656), + VLAN(name='VLAN 7', vid=657) + ) + VLAN.objects.bulk_create(vlans) + + l2vpns = ( + L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=650001), + L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=650002), + L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'), # No RD + ) + L2VPN.objects.bulk_create(l2vpns) + + l2vpnterminations = ( + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[1]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[2]) + ) + L2VPNTermination.objects.bulk_create(l2vpnterminations) + + cls.create_data = [ + { + 'l2vpn': l2vpns[0].pk, + 'assigned_object_type': 'ipam.vlan', + 'assigned_object_id': vlans[3].pk, + }, + { + 'l2vpn': l2vpns[0].pk, + 'assigned_object_type': 'ipam.vlan', + 'assigned_object_id': vlans[4].pk, + }, + { + 'l2vpn': l2vpns[0].pk, + 'assigned_object_type': 'ipam.vlan', + 'assigned_object_id': vlans[5].pk, + }, + ] + + cls.bulk_update_data = { + 'l2vpn': l2vpns[2].pk + } diff --git a/netbox/vpn/tests/test_filtersets.py b/netbox/vpn/tests/test_filtersets.py index 966717f4a..a9eeb1203 100644 --- a/netbox/vpn/tests/test_filtersets.py +++ b/netbox/vpn/tests/test_filtersets.py @@ -1,13 +1,14 @@ +from django.contrib.contenttypes.models import ContentType from django.test import TestCase from dcim.choices import InterfaceTypeChoices -from dcim.models import Interface -from ipam.models import IPAddress -from virtualization.models import VMInterface +from dcim.models import Device, Interface, Site +from ipam.models import IPAddress, VLAN, RouteTarget +from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine +from virtualization.models import VirtualMachine, VMInterface from vpn.choices import * from vpn.filtersets import * from vpn.models import * -from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine class TunnelTestCase(TestCase, ChangeLoggedFilterSetTests): @@ -590,3 +591,163 @@ class IPSecProfileTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) params = {'ipsec_policy': [ipsec_policies[0].name, ipsec_policies[1].name]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = L2VPN.objects.all() + filterset = L2VPNFilterSet + + @classmethod + def setUpTestData(cls): + + route_targets = ( + RouteTarget(name='1:1'), + RouteTarget(name='1:2'), + RouteTarget(name='1:3'), + RouteTarget(name='2:1'), + RouteTarget(name='2:2'), + RouteTarget(name='2:3'), + ) + RouteTarget.objects.bulk_create(route_targets) + + l2vpns = ( + L2VPN(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=65001), + L2VPN(name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VPWS, identifier=65002), + L2VPN(name='L2VPN 3', slug='l2vpn-3', type=L2VPNTypeChoices.TYPE_VPLS), + ) + L2VPN.objects.bulk_create(l2vpns) + l2vpns[0].import_targets.add(route_targets[0]) + l2vpns[1].import_targets.add(route_targets[1]) + l2vpns[2].import_targets.add(route_targets[2]) + l2vpns[0].export_targets.add(route_targets[3]) + l2vpns[1].export_targets.add(route_targets[4]) + l2vpns[2].export_targets.add(route_targets[5]) + + def test_name(self): + params = {'name': ['L2VPN 1', 'L2VPN 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_slug(self): + params = {'slug': ['l2vpn-1', 'l2vpn-2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_identifier(self): + params = {'identifier': ['65001', '65002']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_type(self): + params = {'type': [L2VPNTypeChoices.TYPE_VXLAN, L2VPNTypeChoices.TYPE_VPWS]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_import_targets(self): + route_targets = RouteTarget.objects.filter(name__in=['1:1', '1:2']) + params = {'import_target_id': [route_targets[0].pk, route_targets[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'import_target': [route_targets[0].name, route_targets[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_export_targets(self): + route_targets = RouteTarget.objects.filter(name__in=['2:1', '2:2']) + params = {'export_target_id': [route_targets[0].pk, route_targets[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'export_target': [route_targets[0].name, route_targets[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class L2VPNTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = L2VPNTermination.objects.all() + filterset = L2VPNTerminationFilterSet + + @classmethod + def setUpTestData(cls): + device = create_test_device('Device 1') + interfaces = ( + Interface(name='Interface 1', device=device, type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(name='Interface 2', device=device, type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(name='Interface 3', device=device, type=InterfaceTypeChoices.TYPE_1GE_FIXED), + ) + Interface.objects.bulk_create(interfaces) + + vm = create_test_virtualmachine('Virtual Machine 1') + vminterfaces = ( + VMInterface(name='Interface 1', virtual_machine=vm), + VMInterface(name='Interface 2', virtual_machine=vm), + VMInterface(name='Interface 3', virtual_machine=vm), + ) + VMInterface.objects.bulk_create(vminterfaces) + + vlans = ( + VLAN(name='VLAN 1', vid=101), + VLAN(name='VLAN 2', vid=102), + VLAN(name='VLAN 3', vid=103), + ) + VLAN.objects.bulk_create(vlans) + + l2vpns = ( + L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=65001), + L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=65002), + L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'), # No RD, + ) + L2VPN.objects.bulk_create(l2vpns) + + l2vpnterminations = ( + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]), + L2VPNTermination(l2vpn=l2vpns[1], assigned_object=vlans[1]), + L2VPNTermination(l2vpn=l2vpns[2], assigned_object=vlans[2]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=interfaces[0]), + L2VPNTermination(l2vpn=l2vpns[1], assigned_object=interfaces[1]), + L2VPNTermination(l2vpn=l2vpns[2], assigned_object=interfaces[2]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vminterfaces[0]), + L2VPNTermination(l2vpn=l2vpns[1], assigned_object=vminterfaces[1]), + L2VPNTermination(l2vpn=l2vpns[2], assigned_object=vminterfaces[2]), + ) + L2VPNTermination.objects.bulk_create(l2vpnterminations) + + def test_l2vpn(self): + l2vpns = L2VPN.objects.all()[:2] + params = {'l2vpn_id': [l2vpns[0].pk, l2vpns[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + params = {'l2vpn': [l2vpns[0].slug, l2vpns[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + + def test_content_type(self): + params = {'assigned_object_type_id': ContentType.objects.get(model='vlan').pk} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + + def test_interface(self): + interfaces = Interface.objects.all()[:2] + params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_vminterface(self): + vminterfaces = VMInterface.objects.all()[:2] + params = {'vminterface_id': [vminterfaces[0].pk, vminterfaces[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_vlan(self): + vlans = VLAN.objects.all()[:2] + params = {'vlan_id': [vlans[0].pk, vlans[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'vlan': ['VLAN 1', 'VLAN 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_site(self): + site = Site.objects.all().first() + params = {'site_id': [site.pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + params = {'site': ['site-1']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + + def test_device(self): + device = Device.objects.all().first() + params = {'device_id': [device.pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + params = {'device': ['Device 1']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + + def test_virtual_machine(self): + virtual_machine = VirtualMachine.objects.all().first() + params = {'virtual_machine_id': [virtual_machine.pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + params = {'virtual_machine': ['Virtual Machine 1']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) diff --git a/netbox/vpn/tests/test_models.py b/netbox/vpn/tests/test_models.py new file mode 100644 index 000000000..e464dccd9 --- /dev/null +++ b/netbox/vpn/tests/test_models.py @@ -0,0 +1,79 @@ +from django.core.exceptions import ValidationError +from django.test import TestCase + +from dcim.models import Interface, Device, DeviceRole, DeviceType, Manufacturer, Site +from ipam.models import VLAN +from vpn.models import * + + +class TestL2VPNTermination(TestCase): + + @classmethod + def setUpTestData(cls): + + site = Site.objects.create(name='Site 1') + manufacturer = Manufacturer.objects.create(name='Manufacturer 1') + device_type = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer) + role = DeviceRole.objects.create(name='Switch') + device = Device.objects.create( + name='Device 1', + site=site, + device_type=device_type, + role=role, + status='active' + ) + + interfaces = ( + Interface(name='Interface 1', device=device, type='1000baset'), + Interface(name='Interface 2', device=device, type='1000baset'), + Interface(name='Interface 3', device=device, type='1000baset'), + Interface(name='Interface 4', device=device, type='1000baset'), + Interface(name='Interface 5', device=device, type='1000baset'), + ) + + Interface.objects.bulk_create(interfaces) + + vlans = ( + VLAN(name='VLAN 1', vid=651), + VLAN(name='VLAN 2', vid=652), + VLAN(name='VLAN 3', vid=653), + VLAN(name='VLAN 4', vid=654), + VLAN(name='VLAN 5', vid=655), + VLAN(name='VLAN 6', vid=656), + VLAN(name='VLAN 7', vid=657) + ) + + VLAN.objects.bulk_create(vlans) + + l2vpns = ( + L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=650001), + L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=650002), + L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'), # No RD + ) + L2VPN.objects.bulk_create(l2vpns) + + l2vpnterminations = ( + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[1]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[2]) + ) + + L2VPNTermination.objects.bulk_create(l2vpnterminations) + + def test_duplicate_interface_terminations(self): + device = Device.objects.first() + interface = Interface.objects.filter(device=device).first() + l2vpn = L2VPN.objects.first() + + L2VPNTermination.objects.create(l2vpn=l2vpn, assigned_object=interface) + duplicate = L2VPNTermination(l2vpn=l2vpn, assigned_object=interface) + + self.assertRaises(ValidationError, duplicate.clean) + + def test_duplicate_vlan_terminations(self): + vlan = Interface.objects.first() + l2vpn = L2VPN.objects.first() + + L2VPNTermination.objects.create(l2vpn=l2vpn, assigned_object=vlan) + duplicate = L2VPNTermination(l2vpn=l2vpn, assigned_object=vlan) + self.assertRaises(ValidationError, duplicate.clean) diff --git a/netbox/vpn/tests/test_views.py b/netbox/vpn/tests/test_views.py index 433eca467..4d9080422 100644 --- a/netbox/vpn/tests/test_views.py +++ b/netbox/vpn/tests/test_views.py @@ -1,8 +1,9 @@ from dcim.choices import InterfaceTypeChoices from dcim.models import Interface +from ipam.models import RouteTarget, VLAN +from utilities.testing import ViewTestCases, create_tags, create_test_device from vpn.choices import * from vpn.models import * -from utilities.testing import ViewTestCases, create_tags, create_test_device class TunnelTestCase(ViewTestCases.PrimaryObjectViewTestCase): @@ -506,3 +507,142 @@ class IPSecProfileTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'ike_policy': ike_policies[1].pk, 'ipsec_policy': ipsec_policies[1].pk, } + + +class L2VPNTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = L2VPN + + @classmethod + def setUpTestData(cls): + rts = ( + RouteTarget(name='64534:123'), + RouteTarget(name='64534:321') + ) + RouteTarget.objects.bulk_create(rts) + + l2vpns = ( + L2VPN(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650001'), + L2VPN(name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650002'), + L2VPN(name='L2VPN 3', slug='l2vpn-3', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650003') + ) + L2VPN.objects.bulk_create(l2vpns) + + cls.csv_data = ( + 'name,slug,type,identifier', + 'L2VPN 5,l2vpn-5,vxlan,456', + 'L2VPN 6,l2vpn-6,vxlan,444', + ) + + cls.csv_update_data = ( + 'id,name,description', + f'{l2vpns[0].pk},L2VPN 7,New description 7', + f'{l2vpns[1].pk},L2VPN 8,New description 8', + ) + + cls.bulk_edit_data = { + 'description': 'New Description', + } + + cls.form_data = { + 'name': 'L2VPN 8', + 'slug': 'l2vpn-8', + 'type': L2VPNTypeChoices.TYPE_VXLAN, + 'identifier': 123, + 'description': 'Description', + 'import_targets': [rts[0].pk], + 'export_targets': [rts[1].pk] + } + + +class L2VPNTerminationTestCase( + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.GetObjectChangelogViewTestCase, + ViewTestCases.CreateObjectViewTestCase, + ViewTestCases.EditObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkImportObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase, +): + + model = L2VPNTermination + + @classmethod + def setUpTestData(cls): + device = create_test_device('Device 1') + interface = Interface.objects.create(name='Interface 1', device=device, type='1000baset') + l2vpns = ( + L2VPN(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=650001), + L2VPN(name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=650002), + ) + L2VPN.objects.bulk_create(l2vpns) + + vlans = ( + VLAN(name='Vlan 1', vid=1001), + VLAN(name='Vlan 2', vid=1002), + VLAN(name='Vlan 3', vid=1003), + VLAN(name='Vlan 4', vid=1004), + VLAN(name='Vlan 5', vid=1005), + VLAN(name='Vlan 6', vid=1006) + ) + VLAN.objects.bulk_create(vlans) + + terminations = ( + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[1]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[2]) + ) + L2VPNTermination.objects.bulk_create(terminations) + + cls.form_data = { + 'l2vpn': l2vpns[0].pk, + 'device': device.pk, + 'interface': interface.pk, + } + + cls.csv_data = ( + "l2vpn,vlan", + "L2VPN 1,Vlan 4", + "L2VPN 1,Vlan 5", + "L2VPN 1,Vlan 6", + ) + + cls.csv_update_data = ( + f"id,l2vpn", + f"{terminations[0].pk},{l2vpns[0].name}", + f"{terminations[1].pk},{l2vpns[0].name}", + f"{terminations[2].pk},{l2vpns[0].name}", + ) + + cls.bulk_edit_data = {} + + # TODO: Fix L2VPNTerminationImportForm validation to support bulk updates + def test_bulk_update_objects_with_permission(self): + pass + + # + # Custom assertions + # + + # TODO: Remove this + def assertInstanceEqual(self, instance, data, exclude=None, api=False): + """ + Override parent + """ + if exclude is None: + exclude = [] + + fields = [k for k in data.keys() if k not in exclude] + model_dict = self.model_to_dict(instance, fields=fields, api=api) + + # Omit any dictionary keys which are not instance attributes or have been excluded + relevant_data = { + k: v for k, v in data.items() if hasattr(instance, k) and k not in exclude + } + + # Handle relations on the model + for k, v in model_dict.items(): + if isinstance(v, object) and hasattr(v, 'first'): + model_dict[k] = v.first().pk + + self.assertDictEqual(model_dict, relevant_data) diff --git a/netbox/vpn/urls.py b/netbox/vpn/urls.py index 7fe548245..0e1b1664e 100644 --- a/netbox/vpn/urls.py +++ b/netbox/vpn/urls.py @@ -62,4 +62,20 @@ urlpatterns = [ path('ipsec-profiles/delete/', views.IPSecProfileBulkDeleteView.as_view(), name='ipsecprofile_bulk_delete'), path('ipsec-profiles//', include(get_model_urls('vpn', 'ipsecprofile'))), + # L2VPN + path('l2vpns/', views.L2VPNListView.as_view(), name='l2vpn_list'), + path('l2vpns/add/', views.L2VPNEditView.as_view(), name='l2vpn_add'), + path('l2vpns/import/', views.L2VPNBulkImportView.as_view(), name='l2vpn_import'), + path('l2vpns/edit/', views.L2VPNBulkEditView.as_view(), name='l2vpn_bulk_edit'), + path('l2vpns/delete/', views.L2VPNBulkDeleteView.as_view(), name='l2vpn_bulk_delete'), + path('l2vpns//', include(get_model_urls('vpn', 'l2vpn'))), + + # L2VPN terminations + path('l2vpn-terminations/', views.L2VPNTerminationListView.as_view(), name='l2vpntermination_list'), + path('l2vpn-terminations/add/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_add'), + path('l2vpn-terminations/import/', views.L2VPNTerminationBulkImportView.as_view(), name='l2vpntermination_import'), + path('l2vpn-terminations/edit/', views.L2VPNTerminationBulkEditView.as_view(), name='l2vpntermination_bulk_edit'), + path('l2vpn-terminations/delete/', views.L2VPNTerminationBulkDeleteView.as_view(), name='l2vpntermination_bulk_delete'), + path('l2vpn-terminations//', include(get_model_urls('vpn', 'l2vpntermination'))), + ] diff --git a/netbox/vpn/views.py b/netbox/vpn/views.py index 56eadc077..f230e4828 100644 --- a/netbox/vpn/views.py +++ b/netbox/vpn/views.py @@ -1,4 +1,6 @@ +from ipam.tables import RouteTargetTable from netbox.views import generic +from tenancy.views import ObjectContactsView from utilities.utils import count_related from utilities.views import register_model_view from . import filtersets, forms, tables @@ -332,3 +334,112 @@ class IPSecProfileBulkDeleteView(generic.BulkDeleteView): queryset = IPSecProfile.objects.all() filterset = filtersets.IPSecProfileFilterSet table = tables.IPSecProfileTable + + +# L2VPN + +class L2VPNListView(generic.ObjectListView): + queryset = L2VPN.objects.all() + table = tables.L2VPNTable + filterset = filtersets.L2VPNFilterSet + filterset_form = forms.L2VPNFilterForm + + +@register_model_view(L2VPN) +class L2VPNView(generic.ObjectView): + queryset = L2VPN.objects.all() + + def get_extra_context(self, request, instance): + import_targets_table = RouteTargetTable( + instance.import_targets.prefetch_related('tenant'), + orderable=False + ) + export_targets_table = RouteTargetTable( + instance.export_targets.prefetch_related('tenant'), + orderable=False + ) + + return { + 'import_targets_table': import_targets_table, + 'export_targets_table': export_targets_table, + } + + +@register_model_view(L2VPN, 'edit') +class L2VPNEditView(generic.ObjectEditView): + queryset = L2VPN.objects.all() + form = forms.L2VPNForm + + +@register_model_view(L2VPN, 'delete') +class L2VPNDeleteView(generic.ObjectDeleteView): + queryset = L2VPN.objects.all() + + +class L2VPNBulkImportView(generic.BulkImportView): + queryset = L2VPN.objects.all() + model_form = forms.L2VPNImportForm + + +class L2VPNBulkEditView(generic.BulkEditView): + queryset = L2VPN.objects.all() + filterset = filtersets.L2VPNFilterSet + table = tables.L2VPNTable + form = forms.L2VPNBulkEditForm + + +class L2VPNBulkDeleteView(generic.BulkDeleteView): + queryset = L2VPN.objects.all() + filterset = filtersets.L2VPNFilterSet + table = tables.L2VPNTable + + +@register_model_view(L2VPN, 'contacts') +class L2VPNContactsView(ObjectContactsView): + queryset = L2VPN.objects.all() + + +# +# L2VPN terminations +# + +class L2VPNTerminationListView(generic.ObjectListView): + queryset = L2VPNTermination.objects.all() + table = tables.L2VPNTerminationTable + filterset = filtersets.L2VPNTerminationFilterSet + filterset_form = forms.L2VPNTerminationFilterForm + + +@register_model_view(L2VPNTermination) +class L2VPNTerminationView(generic.ObjectView): + queryset = L2VPNTermination.objects.all() + + +@register_model_view(L2VPNTermination, 'edit') +class L2VPNTerminationEditView(generic.ObjectEditView): + queryset = L2VPNTermination.objects.all() + form = forms.L2VPNTerminationForm + template_name = 'vpn/l2vpntermination_edit.html' + + +@register_model_view(L2VPNTermination, 'delete') +class L2VPNTerminationDeleteView(generic.ObjectDeleteView): + queryset = L2VPNTermination.objects.all() + + +class L2VPNTerminationBulkImportView(generic.BulkImportView): + queryset = L2VPNTermination.objects.all() + model_form = forms.L2VPNTerminationImportForm + + +class L2VPNTerminationBulkEditView(generic.BulkEditView): + queryset = L2VPNTermination.objects.all() + filterset = filtersets.L2VPNTerminationFilterSet + table = tables.L2VPNTerminationTable + form = forms.L2VPNTerminationBulkEditForm + + +class L2VPNTerminationBulkDeleteView(generic.BulkDeleteView): + queryset = L2VPNTermination.objects.all() + filterset = filtersets.L2VPNTerminationFilterSet + table = tables.L2VPNTerminationTable From 40763b58bdad0892efff418ade3d98fc8cf184d6 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Tue, 28 Nov 2023 14:01:03 -0800 Subject: [PATCH 07/22] 14299 change webhook timestamp to isoformat (#14331) * 14299 change timestamp to isoformat * Omit redundant str() casting --------- Co-authored-by: Jeremy Stretch --- netbox/core/models/jobs.py | 2 +- netbox/extras/webhooks.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py index 61b0e64fa..d52cbe165 100644 --- a/netbox/core/models/jobs.py +++ b/netbox/core/models/jobs.py @@ -229,7 +229,7 @@ class Job(models.Model): model_name=self.object_type.model, event=event, data=self.data, - timestamp=str(timezone.now()), + timestamp=timezone.now().isoformat(), username=self.user.username, retry=get_rq_retry() ) diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index 1fc869ee8..a22f73c27 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -115,7 +115,7 @@ def flush_webhooks(queue): event=data['event'], data=data['data'], snapshots=data['snapshots'], - timestamp=str(timezone.now()), + timestamp=timezone.now().isoformat(), username=data['username'], request_id=data['request_id'], retry=get_rq_retry() From 7d413ea3c2dccb78010a4664a23ac1c2b5494a8a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 28 Nov 2023 17:02:07 -0500 Subject: [PATCH 08/22] Fixes #14343: Set order_by accessor for asn_asdot column (#14369) --- netbox/ipam/tables/asn.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/ipam/tables/asn.py b/netbox/ipam/tables/asn.py index 6bb15523e..bbe38dc1a 100644 --- a/netbox/ipam/tables/asn.py +++ b/netbox/ipam/tables/asn.py @@ -48,6 +48,7 @@ class ASNTable(TenancyColumnsMixin, NetBoxTable): asn_asdot = tables.Column( accessor=tables.A('asn_asdot'), linkify=True, + order_by=tables.A('asn'), verbose_name=_('ASDOT') ) site_count = columns.LinkedCountColumn( From 080da68b6adfd9e9d93c4ec2af92e997dc26e6c8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 28 Nov 2023 14:46:36 -0500 Subject: [PATCH 09/22] Fixes #14349: Fix custom validation support for DataSource --- netbox/core/models/data.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/core/models/data.py b/netbox/core/models/data.py index 54a43c7ef..9e41e8446 100644 --- a/netbox/core/models/data.py +++ b/netbox/core/models/data.py @@ -122,6 +122,7 @@ class DataSource(JobsMixin, PrimaryModel): ) def clean(self): + super().clean() # Ensure URL scheme matches selected type if self.type == DataSourceTypeChoices.LOCAL and self.url_scheme not in ('file', ''): From fc7d6e1387b3fcfd0490f94f249f54cea68bda04 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 28 Nov 2023 17:04:10 -0500 Subject: [PATCH 10/22] Fixes #14325: Ensure expanded numeric arrays are ordered (#14370) * Fixes #14325: Ensure expanded numeric arrays are ordered * Remove redundant casting to --- netbox/utilities/forms/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/utilities/forms/utils.py b/netbox/utilities/forms/utils.py index 4d737f163..64864a6c1 100644 --- a/netbox/utilities/forms/utils.py +++ b/netbox/utilities/forms/utils.py @@ -40,7 +40,7 @@ def parse_numeric_range(string, base=10): except ValueError: raise forms.ValidationError(f'Range "{dash_range}" is invalid.') values.extend(range(begin, end)) - return list(set(values)) + return sorted(set(values)) def parse_alphanumeric_range(string): From 01ba4ce129aa89d74a41b4fbd6994d7559019f96 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 28 Nov 2023 16:58:13 -0500 Subject: [PATCH 11/22] Fixes #14242: Enable export templates for contact assignments --- netbox/tenancy/models/contacts.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py index e8327248d..96ea053f7 100644 --- a/netbox/tenancy/models/contacts.py +++ b/netbox/tenancy/models/contacts.py @@ -5,7 +5,7 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel -from netbox.models.features import TagsMixin +from netbox.models.features import ExportTemplatesMixin, TagsMixin from tenancy.choices import * __all__ = ( @@ -109,7 +109,7 @@ class Contact(PrimaryModel): return reverse('tenancy:contact', args=[self.pk]) -class ContactAssignment(ChangeLoggedModel, TagsMixin): +class ContactAssignment(ChangeLoggedModel, ExportTemplatesMixin, TagsMixin): content_type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE From 792b353f6477ce9bb3bc2a234dd6313c9fa38402 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 28 Nov 2023 14:40:54 -0500 Subject: [PATCH 12/22] Fixes #14363: Fix bulk editing of interfaces assigned to VM with no cluster --- netbox/virtualization/forms/bulk_edit.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index a33ffac53..e5ab24f2e 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -294,9 +294,10 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm): # Check interface sites. First interface should set site, further interfaces will either continue the # loop or reset back to no site and break the loop. for interface in interfaces: + vm_site = interface.virtual_machine.site or interface.virtual_machine.cluster.site if site is None: - site = interface.virtual_machine.cluster.site - elif interface.virtual_machine.cluster.site is not site: + site = vm_site + elif vm_site is not site: site = None break From c43c63a817444703f068cb6b05fd4b5c5a702f5e Mon Sep 17 00:00:00 2001 From: Josef Johansson Date: Wed, 29 Nov 2023 08:31:33 +0100 Subject: [PATCH 13/22] 14346 fix missing function call convert In PR #13958 (commit 8224644) _get_report was modified to do the call on the variable without changing the call later on. This commit fixes that and removes the call on the variable. Signed-off-by: Josef Johansson --- netbox/extras/api/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index f518275e0..830982e74 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -283,7 +283,7 @@ class ReportViewSet(ViewSet): # Retrieve and run the Report. This will create a new Job. module, report_cls = self._get_report(pk) - report = report_cls() + report = report_cls input_serializer = serializers.ReportInputSerializer( data=request.data, context={'report': report} From 3a3d43911cd45e1b9ed09a39f8e172f987ed36f8 Mon Sep 17 00:00:00 2001 From: Vincent Simonin Date: Wed, 29 Nov 2023 21:59:54 +0100 Subject: [PATCH 14/22] Fixed password was not hashed on REST API update (#14340) * Fixed password was not hashed on REST API update * When we updated a user password with a REST API call the password was stored in clear in plain text in the database. * Following code review * Move test on UserTest class * Call `super().update()` in overriding `update` method * Return directly the result of `super().update()` --- netbox/users/api/serializers.py | 10 ++++++++++ netbox/users/tests/test_api.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index 75ab877cf..c9775e39a 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -52,6 +52,16 @@ class UserSerializer(ValidatedModelSerializer): return user + def update(self, instance, validated_data): + """ + Ensure proper updated password hash generation. + """ + password = validated_data.pop('password', None) + if password is not None: + instance.set_password(password) + + return super().update(instance, validated_data) + @extend_schema_field(OpenApiTypes.STR) def get_display(self, obj): if full_name := obj.get_full_name(): diff --git a/netbox/users/tests/test_api.py b/netbox/users/tests/test_api.py index 001142410..090ccc263 100644 --- a/netbox/users/tests/test_api.py +++ b/netbox/users/tests/test_api.py @@ -54,6 +54,38 @@ class UserTest(APIViewTestCases.APIViewTestCase): ) User.objects.bulk_create(users) + def test_that_password_is_changed(self): + """ + Test that password is changed + """ + + obj_perm = ObjectPermission( + name='Test permission', + actions=['change'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + + user_credentials = { + 'username': 'user1', + 'password': 'abc123', + } + user = User.objects.create_user(**user_credentials) + + data = { + 'password': 'newpassword' + } + url = reverse('users-api:user-detail', kwargs={'pk': user.id}) + + response = self.client.patch(url, data, format='json', **self.header) + + self.assertEqual(response.status_code, 200) + + updated_user = User.objects.get(id=user.id) + + self.assertTrue(updated_user.check_password(data['password'])) + class GroupTest(APIViewTestCases.APIViewTestCase): model = Group From ff021a8e4ece4655590e5555c030ed0220543f32 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Thu, 30 Nov 2023 02:50:59 +0530 Subject: [PATCH 15/22] Adds region hierarchy in templates (#14213) * initial work to render hierarchical region #13735 * adds site display #13735 * cleanup #13735 * adds display region tag #13735 * refactored region hierarchy #13735 * refactored region hierarchy #13735 * renamed display_region to nested_tree #13735 * Make render_tree suitable for generic use * Remove errant item from __all__ --------- Co-authored-by: Jeremy Stretch --- netbox/templates/dcim/device.html | 23 ++----------- netbox/templates/dcim/rack.html | 23 +++++-------- netbox/templates/dcim/rackreservation.html | 38 +++++++++++----------- netbox/templates/dcim/site.html | 19 ++--------- netbox/templates/ipam/prefix.html | 20 ++++++------ netbox/templates/ipam/vlan.html | 20 ++++++------ netbox/utilities/templatetags/mptt.py | 20 ++++++++++++ 7 files changed, 73 insertions(+), 90 deletions(-) create mode 100644 netbox/utilities/templatetags/mptt.py diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 5fa6a3314..39e78c81b 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -5,6 +5,7 @@ {% load helpers %} {% load plugins %} {% load i18n %} +{% load mptt %} {% block content %}
    @@ -15,16 +16,7 @@ - + @@ -32,16 +24,7 @@ - + diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 671c7ab2e..857061d00 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -4,6 +4,7 @@ {% load static %} {% load plugins %} {% load i18n %} +{% load mptt %} {% block content %}
    @@ -15,26 +16,18 @@
    {% trans "Region" %} - {% if object.site.region %} - {% for region in object.site.region.get_ancestors %} - {{ region|linkify }} / - {% endfor %} - {{ object.site.region|linkify }} - {% else %} - {{ ''|placeholder }} - {% endif %} - {% nested_tree object.site.region %}
    {% trans "Site" %}
    {% trans "Location" %} - {% if object.location %} - {% for location in object.location.get_ancestors %} - {{ location|linkify }} / - {% endfor %} - {{ object.location|linkify }} - {% else %} - {{ ''|placeholder }} - {% endif %} - {% nested_tree object.location %}
    {% trans "Rack" %}
    - + + + + + - + diff --git a/netbox/templates/dcim/rackreservation.html b/netbox/templates/dcim/rackreservation.html index 8edb75f32..3d145145f 100644 --- a/netbox/templates/dcim/rackreservation.html +++ b/netbox/templates/dcim/rackreservation.html @@ -4,6 +4,7 @@ {% load static %} {% load plugins %} {% load i18n %} +{% load mptt %} {% block breadcrumbs %} {{ block.super }} @@ -20,25 +21,24 @@
    {% trans "Site" %}{% trans "Region" %} - {% if object.site.region %} - {{ object.site.region|linkify }} / - {% endif %} - {{ object.site|linkify }} + {% nested_tree object.site.region %}
    {% trans "Site" %}{{ object.site|linkify }}
    {% trans "Location" %} - {% if object.location %} - {% for location in object.location.get_ancestors %} - {{ location|linkify }} / - {% endfor %} - {{ object.location|linkify }} - {% else %} - {{ ''|placeholder }} - {% endif %} - {% nested_tree object.location %}
    {% trans "Facility ID" %}
    - {% with rack=object.rack %} - - - - - - - - - - - - - {% endwith %} + + + + + + + + + + + + + + + +
    {% trans "Site" %} - {% if rack.site.region %} - {{ rack.site.region|linkify }} / - {% endif %} - {{ rack.site|linkify }} -
    {% trans "Location" %}{{ rack.location|linkify|placeholder }}
    {% trans "Rack" %}{{ rack|linkify }}
    {% trans "Region" %} + {% nested_tree object.rack.site.region %} +
    {% trans "Site" %}{{ object.rack.site|linkify }}
    {% trans "Location" %}{{ object.rack.location|linkify|placeholder }}
    {% trans "Rack" %}{{ object.rack|linkify }}
    diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 7f43a0ab3..16a870182 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -3,6 +3,7 @@ {% load plugins %} {% load tz %} {% load i18n %} +{% load mptt %} {% block breadcrumbs %} {{ block.super }} @@ -29,27 +30,13 @@ {% trans "Region" %} - {% if object.region %} - {% for region in object.region.get_ancestors %} - {{ region|linkify }} / - {% endfor %} - {{ object.region|linkify }} - {% else %} - {{ ''|placeholder }} - {% endif %} + {% nested_tree object.region %} {% trans "Group" %} - {% if object.group %} - {% for group in object.group.get_ancestors %} - {{ group|linkify }} / - {% endfor %} - {{ object.group|linkify }} - {% else %} - {{ ''|placeholder }} - {% endif %} + {% nested_tree object.group %} diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index 71b240ced..ec6138d69 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -3,6 +3,7 @@ {% load helpers %} {% load plugins %} {% load i18n %} +{% load mptt %} {% block content %}
    @@ -44,18 +45,17 @@ {% endif %} + {% if object.site.region %} + + {% trans "Region" %} + + {% nested_tree object.site.region %} + + + {% endif %} {% trans "Site" %} - - {% if object.site %} - {% if object.site.region %} - {{ object.site.region|linkify }} / - {% endif %} - {{ object.site|linkify }} - {% else %} - {{ ''|placeholder }} - {% endif %} - + {{ object.site|linkify|placeholder }} {% trans "VLAN" %} diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index 4ca045d4b..a5ed9d643 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -3,6 +3,7 @@ {% load render_table from django_tables2 %} {% load plugins %} {% load i18n %} +{% load mptt %} {% block content %}
    @@ -13,18 +14,17 @@
    + {% if object.site.region %} + + + + + {% endif %} - + diff --git a/netbox/utilities/templatetags/mptt.py b/netbox/utilities/templatetags/mptt.py new file mode 100644 index 000000000..783c2654f --- /dev/null +++ b/netbox/utilities/templatetags/mptt.py @@ -0,0 +1,20 @@ +from django import template +from django.utils.safestring import mark_safe + +register = template.Library() + + +@register.simple_tag() +def nested_tree(obj): + """ + Renders the entire hierarchy of a recursively-nested object (such as Region or SiteGroup). + """ + if not obj: + return mark_safe('—') + + nodes = obj.get_ancestors(include_self=True) + return mark_safe( + ' / '.join( + f'{node}' for node in nodes + ) + ) From 290aae592dc670857b00de082cec9677e203ff68 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Thu, 30 Nov 2023 02:55:16 +0530 Subject: [PATCH 16/22] Raises validation error if file path and root are not unique (#14232) * raises validation error if file path and root are not unique #14187 * review changes #14187 --- netbox/core/models/files.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/netbox/core/models/files.py b/netbox/core/models/files.py index 38d82463e..a9e0e7f00 100644 --- a/netbox/core/models/files.py +++ b/netbox/core/models/files.py @@ -2,6 +2,7 @@ import logging import os from django.conf import settings +from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from django.utils.translation import gettext as _ @@ -84,6 +85,14 @@ class ManagedFile(SyncedDataMixin, models.Model): self.file_path = os.path.basename(self.data_path) self.data_file.write_to_disk(self.full_path, overwrite=True) + def clean(self): + super().clean() + + # Ensure that the file root and path make a unique pair + if self._meta.model.objects.filter(file_root=self.file_root, file_path=self.file_path).exclude(pk=self.pk).exists(): + raise ValidationError( + f"A {self._meta.verbose_name.lower()} with this file path already exists ({self.file_root}/{self.file_path}).") + def delete(self, *args, **kwargs): # Delete file from disk try: From 6dddb6c9d22f751dc50b4b0267a768236bd275b1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 29 Nov 2023 16:37:08 -0500 Subject: [PATCH 17/22] Fixes #14199: Fix jobs count for reports with a custom name --- netbox/extras/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 55b73d29d..2c59c5235 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1073,7 +1073,7 @@ class ReportJobsView(ContentTypePermissionRequiredMixin, View): jobs = Job.objects.filter( object_type=object_type, object_id=module.pk, - name=report.name + name=report.class_name ) jobs_table = JobTable( From 82591ad8a1ed590721f96a5778d21e4f78d9daa7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 29 Nov 2023 16:47:26 -0500 Subject: [PATCH 18/22] Fixes #14056: Record a pre-change snapshot when bulk editing objects via CSV --- netbox/netbox/views/generic/bulk_views.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 676e3f5af..c5a08c80a 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -394,6 +394,10 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): form.add_error('data', f"Row {i}: Object with ID {object_id} does not exist") raise ValidationError('') + # Take a snapshot for change logging + if instance.pk and hasattr(instance, 'snapshot'): + instance.snapshot() + # Instantiate the model form for the object model_form_kwargs = { 'data': record, From ac3fc25dfdc09a9c6fb3de76bc2cf3993e45626d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 29 Nov 2023 16:55:10 -0500 Subject: [PATCH 19/22] Fixes #14239: Fix CustomFieldChoiceSet search filter --- netbox/extras/filtersets.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index fec067263..b33e70488 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -122,8 +122,7 @@ class CustomFieldChoiceSetFilterSet(BaseFilterSet): return queryset return queryset.filter( Q(name__icontains=value) | - Q(description__icontains=value) | - Q(extra_choices__contains=value) + Q(description__icontains=value) ) def filter_by_choice(self, queryset, name, value): From 0a8eb7fcbe7beb20738aeacc5c0d3eac288cefcf Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 29 Nov 2023 17:25:10 -0500 Subject: [PATCH 20/22] Update changelog --- docs/release-notes/version-3.6.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/release-notes/version-3.6.md b/docs/release-notes/version-3.6.md index b8d316218..ddf571965 100644 --- a/docs/release-notes/version-3.6.md +++ b/docs/release-notes/version-3.6.md @@ -2,6 +2,25 @@ ## v3.6.6 (FUTURE) +### Enhancements + +* [#13735](https://github.com/netbox-community/netbox/issues/13735) - Show complete region hierarchy in UI for all relevant objects + +### Bug Fixes + +* [#14056](https://github.com/netbox-community/netbox/issues/14056) - Record a pre-change snapshot when bulk editing objects via CSV +* [#14187](https://github.com/netbox-community/netbox/issues/14187) - Raise a validation error when attempting to create a duplicate script or report +* [#14199](https://github.com/netbox-community/netbox/issues/14199) - Fix jobs list for reports with a custom name +* [#14239](https://github.com/netbox-community/netbox/issues/14239) - Fix CustomFieldChoiceSet search filter +* [#14242](https://github.com/netbox-community/netbox/issues/14242) - Enable export templates for contact assignments +* [#14299](https://github.com/netbox-community/netbox/issues/14299) - Webhook timestamps should be in proper ISO 8601 format +* [#14325](https://github.com/netbox-community/netbox/issues/14325) - Fix numeric ordering of service ports +* [#14339](https://github.com/netbox-community/netbox/issues/14339) - Correctly hash local user password when set via REST API +* [#14343](https://github.com/netbox-community/netbox/issues/14343) - Fix ordering ASN table by ASDOT column +* [#14346](https://github.com/netbox-community/netbox/issues/14346) - Fix running reports via REST API +* [#14349](https://github.com/netbox-community/netbox/issues/14349) - Fix custom validation support for remote data sources +* [#14363](https://github.com/netbox-community/netbox/issues/14363) - Fix bulk editing of interfaces assigned to VM with no cluster + --- ## v3.6.5 (2023-11-09) From 04fd45581d1213af0da12e7ffe74b88b5f5866b0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 29 Nov 2023 19:16:30 -0500 Subject: [PATCH 21/22] Release v3.6.6 --- .github/ISSUE_TEMPLATE/bug_report.yaml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yaml | 2 +- docs/release-notes/version-3.6.md | 2 +- netbox/netbox/settings.py | 2 +- requirements.txt | 18 +++++++++--------- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 48c14a2da..5e936c5ec 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.6.5 + placeholder: v3.6.6 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 0525659ae..34103e616 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.6.5 + placeholder: v3.6.6 validations: required: true - type: dropdown diff --git a/docs/release-notes/version-3.6.md b/docs/release-notes/version-3.6.md index ddf571965..6f81e4526 100644 --- a/docs/release-notes/version-3.6.md +++ b/docs/release-notes/version-3.6.md @@ -1,6 +1,6 @@ # NetBox v3.6 -## v3.6.6 (FUTURE) +## v3.6.6 (2023-11-29) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 5a5a89984..9072dd364 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW # Environment setup # -VERSION = '3.6.6-dev' +VERSION = '3.6.6' # Hostname HOSTNAME = platform.node() diff --git a/requirements.txt b/requirements.txt index 16bafe62f..b99f16e76 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,18 +1,18 @@ bleach==6.1.0 Django==4.2.7 -django-cors-headers==4.3.0 +django-cors-headers==4.3.1 django-debug-toolbar==4.2.0 -django-filter==23.3 +django-filter==23.4 django-graphiql-debug-toolbar==0.2.0 django-mptt==0.14.0 django-pglocks==1.0.4 django-prometheus==2.3.1 django-redis==5.4.0 django-rich==1.8.0 -django-rq==2.8.1 +django-rq==2.9.0 django-tables2==2.6.0 django-taggit==4.0.0 -django-timezone-field==6.0.1 +django-timezone-field==6.1.0 djangorestframework==3.14.0 drf-spectacular==0.26.5 drf-spectacular-sidecar==2023.10.1 @@ -21,16 +21,16 @@ graphene-django==3.0.0 gunicorn==21.2.0 Jinja2==3.1.2 Markdown==3.3.7 -mkdocs-material==9.4.8 -mkdocstrings[python-legacy]==0.23.0 +mkdocs-material==9.4.14 +mkdocstrings[python-legacy]==0.24.0 netaddr==0.9.0 Pillow==10.1.0 -psycopg[binary,pool]==3.1.12 +psycopg[binary,pool]==3.1.13 PyYAML==6.0.1 requests==2.31.0 -sentry-sdk==1.34.0 +sentry-sdk==1.38.0 social-auth-app-django==5.4.0 -social-auth-core[openidconnect]==4.5.0 +social-auth-core[openidconnect]==4.5.1 svgwrite==1.4.3 tablib==3.5.0 tzdata==2023.3 From 9d09916f6e237a6d18c2e952e7a9da91f21d3381 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 29 Nov 2023 19:32:45 -0500 Subject: [PATCH 22/22] PRVB --- docs/release-notes/version-3.6.md | 4 ++++ netbox/netbox/settings.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.6.md b/docs/release-notes/version-3.6.md index 6f81e4526..10e93be1e 100644 --- a/docs/release-notes/version-3.6.md +++ b/docs/release-notes/version-3.6.md @@ -1,5 +1,9 @@ # NetBox v3.6 +## v3.6.7 (FUTURE) + +--- + ## v3.6.6 (2023-11-29) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 9072dd364..824bc4605 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW # Environment setup # -VERSION = '3.6.6' +VERSION = '3.6.7-dev' # Hostname HOSTNAME = platform.node()
    {% trans "Region" %} + {% nested_tree object.site.region %} +
    {% trans "Site" %} - {% if object.site %} - {% if object.site.region %} - {{ object.site.region|linkify }} / - {% endif %} - {{ object.site|linkify }} - {% else %} - {{ ''|placeholder }} - {% endif %} - {{ object.site|linkify|placeholder }}
    {% trans "Group" %}