diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index e166c44ab..0d01435a3 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -599,6 +599,12 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): object_id_field='assigned_object_id', related_query_name='interface' ) + fhrp_group_assignments = GenericRelation( + to='ipam.FHRPGroupAssignment', + content_type_field='content_type', + object_id_field='object_id', + related_query_name='interface' + ) clone_fields = ['device', 'parent', 'bridge', 'lag', 'type', 'mgmt_only'] diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 5079e01a5..daf3e13b4 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -15,7 +15,7 @@ from django.views.generic import View from circuits.models import Circuit from extras.views import ObjectChangeLogView, ObjectConfigContextView, ObjectJournalView from ipam.models import IPAddress, Prefix, Service, VLAN -from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable +from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable from netbox.views import generic from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count @@ -1741,7 +1741,7 @@ class InterfaceView(generic.ObjectView): def get_extra_context(self, request, instance): # Get assigned IP addresses - ipaddress_table = InterfaceIPAddressTable( + ipaddress_table = AssignedIPAddressesTable( data=instance.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'), orderable=False ) diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index a52a6a03c..e94dad24f 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -5,6 +5,7 @@ from netbox.api import WritableNestedSerializer __all__ = [ 'NestedAggregateSerializer', + 'NestedFHRPGroupSerializer', 'NestedIPAddressSerializer', 'NestedIPRangeSerializer', 'NestedPrefixSerializer', @@ -65,6 +66,18 @@ class NestedAggregateSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'family', 'prefix'] +# +# FHRP groups +# + +class NestedFHRPGroupSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroup-detail') + + class Meta: + model = models.FHRPGroup + fields = ['id', 'url', 'display', 'protocol', 'group_id'] + + # # VLANs # diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 2b221fdab..e2a3c1954 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -92,6 +92,43 @@ class AggregateSerializer(PrimaryModelSerializer): read_only_fields = ['family'] +# +# FHRP Groups +# + +class FHRPGroupSerializer(PrimaryModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroup-detail') + + class Meta: + model = FHRPGroup + fields = [ + 'id', 'url', 'display', 'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'tags', + 'custom_fields', 'created', 'last_updated', + ] + + +class FHRPGroupAssignmentSerializer(PrimaryModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactassignment-detail') + content_type = ContentTypeField( + queryset=ContentType.objects.all() + ) + object = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = FHRPGroupAssignment + fields = [ + 'id', 'url', 'display', 'content_type', 'object_id', 'object', 'priority', 'created', 'last_updated', + ] + + @swagger_serializer_method(serializer_or_field=serializers.DictField) + def get_object(self, obj): + if obj.object is None: + return None + serializer = get_serializer_for_model(obj.object, prefix='Nested') + context = {'request': self.context['request']} + return serializer(obj.object, context=context).data + + # # VLANs # diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index 06c4ab0ea..60f4b6b72 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -27,6 +27,10 @@ router.register('ip-ranges', views.IPRangeViewSet) # IP addresses router.register('ip-addresses', views.IPAddressViewSet) +# FHRP groups +router.register('fhrp-groups', views.FHRPGroupViewSet) +router.register('fhrp-group-assignments', views.FHRPGroupAssignmentViewSet) + # VLANs router.register('vlan-groups', views.VLANGroupViewSet) router.register('vlans', views.VLANViewSet) diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index a043bd88c..6199c0caf 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -119,6 +119,22 @@ class IPAddressViewSet(CustomFieldModelViewSet): filterset_class = filtersets.IPAddressFilterSet +# +# FHRP groups +# + +class FHRPGroupViewSet(CustomFieldModelViewSet): + queryset = FHRPGroup.objects.prefetch_related('tags') + serializer_class = serializers.FHRPGroupSerializer + filterset_class = filtersets.FHRPGroupFilterSet + + +class FHRPGroupAssignmentViewSet(CustomFieldModelViewSet): + queryset = FHRPGroupAssignment.objects.prefetch_related('group') + serializer_class = serializers.FHRPGroupAssignmentSerializer + filterset_class = filtersets.FHRPGroupAssignmentFilterSet + + # # VLAN groups # diff --git a/netbox/ipam/choices.py b/netbox/ipam/choices.py index e3a45f577..638ef62f6 100644 --- a/netbox/ipam/choices.py +++ b/netbox/ipam/choices.py @@ -124,6 +124,38 @@ class IPAddressRoleChoices(ChoiceSet): } +# +# FHRP +# + +class FHRPGroupProtocolChoices(ChoiceSet): + + PROTOCOL_VRRP2 = 'vrrp2' + PROTOCOL_VRRP3 = 'vrrp3' + PROTOCOL_HSRP = 'hsrp' + PROTOCOL_GLBP = 'glbp' + PROTOCOL_CARP = 'carp' + + CHOICES = ( + (PROTOCOL_VRRP2, 'VRRPv2'), + (PROTOCOL_VRRP3, 'VRRPv3'), + (PROTOCOL_HSRP, 'HSRP'), + (PROTOCOL_GLBP, 'GLBP'), + (PROTOCOL_CARP, 'CARP'), + ) + + +class FHRPGroupAuthTypeChoices(ChoiceSet): + + AUTHENTICATION_PLAINTEXT = 'plaintext' + AUTHENTICATION_MD5 = 'md5' + + CHOICES = ( + (AUTHENTICATION_PLAINTEXT, 'Plaintext'), + (AUTHENTICATION_MD5, 'MD5'), + ) + + # # VLANs # diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index 9dd9328b8..1c370a65d 100644 --- a/netbox/ipam/constants.py +++ b/netbox/ipam/constants.py @@ -34,6 +34,7 @@ PREFIX_LENGTH_MAX = 127 # IPv6 IPADDRESS_ASSIGNMENT_MODELS = Q( Q(app_label='dcim', model='interface') | + Q(app_label='ipam', model='fhrpgroup') | Q(app_label='virtualization', model='vminterface') ) diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 56d23387f..89bb61c02 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -7,7 +7,7 @@ from netaddr.core import AddrFormatError from dcim.models import Device, Interface, Region, Site, SiteGroup from extras.filters import TagFilter -from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet +from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet from tenancy.filtersets import TenancyFilterSet from utilities.filters import ( ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter, @@ -19,6 +19,8 @@ from .models import * __all__ = ( 'AggregateFilterSet', + 'FHRPGroupAssignmentFilterSet', + 'FHRPGroupFilterSet', 'IPAddressFilterSet', 'IPRangeFilterSet', 'PrefixFilterSet', @@ -611,6 +613,39 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet): return queryset.exclude(assigned_object_id__isnull=value) +class FHRPGroupFilterSet(PrimaryModelFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + protocol = django_filters.MultipleChoiceFilter( + choices=FHRPGroupProtocolChoices + ) + auth_type = django_filters.MultipleChoiceFilter( + choices=FHRPGroupAuthTypeChoices + ) + tag = TagFilter() + + class Meta: + model = FHRPGroup + fields = ['id', 'protocol', 'group_id', 'auth_type'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(description__icontains=value) + ) + + +class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet): + content_type = ContentTypeFilter() + + class Meta: + model = FHRPGroupAssignment + fields = ['id', 'content_type_id', 'priority'] + + class VLANGroupFilterSet(OrganizationalModelFilterSet): q = django_filters.CharFilter( method='search', diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 43bf40f88..7f910faa4 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -13,6 +13,7 @@ from utilities.forms import ( __all__ = ( 'AggregateBulkEditForm', + 'FHRPGroupBulkEditForm', 'IPAddressBulkEditForm', 'IPRangeBulkEditForm', 'PrefixBulkEditForm', @@ -280,6 +281,41 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelB ] +class FHRPGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=FHRPGroup.objects.all(), + widget=forms.MultipleHiddenInput() + ) + protocol = forms.ChoiceField( + choices=add_blank_choice(FHRPGroupProtocolChoices), + required=False, + widget=StaticSelect() + ) + group_id = forms.IntegerField( + min_value=0, + required=False, + label='Group ID' + ) + auth_type = forms.ChoiceField( + choices=add_blank_choice(FHRPGroupAuthTypeChoices), + required=False, + widget=StaticSelect(), + label='Authentication type' + ) + auth_key = forms.CharField( + max_length=255, + required=False, + label='Authentication key' + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['auth_type', 'auth_key', 'description'] + + class VLANGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=VLANGroup.objects.all(), diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index 49d5014f9..fd9a9e715 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -12,6 +12,7 @@ from virtualization.models import VirtualMachine, VMInterface __all__ = ( 'AggregateCSVForm', + 'FHRPGroupCSVForm', 'IPAddressCSVForm', 'IPRangeCSVForm', 'PrefixCSVForm', @@ -283,6 +284,20 @@ class IPAddressCSVForm(CustomFieldModelCSVForm): return ipaddress +class FHRPGroupCSVForm(CustomFieldModelCSVForm): + protocol = CSVChoiceField( + choices=FHRPGroupProtocolChoices + ) + auth_type = CSVChoiceField( + choices=FHRPGroupAuthTypeChoices, + required=False + ) + + class Meta: + model = FHRPGroup + fields = ('protocol', 'group_id', 'auth_type', 'auth_key', 'description') + + class VLANGroupCSVForm(CustomFieldModelCSVForm): slug = SlugField() scope_type = CSVContentTypeField( diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index 415664f62..67927a016 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -14,6 +14,7 @@ from utilities.forms import ( __all__ = ( 'AggregateFilterForm', + 'FHRPGroupFilterForm', 'IPAddressFilterForm', 'IPRangeFilterForm', 'PrefixFilterForm', @@ -356,6 +357,41 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFil tag = TagFilterField(model) +class FHRPGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): + model = FHRPGroup + field_groups = ( + ('q', 'tag'), + ('protocol', 'group_id'), + ('auth_type', 'auth_key'), + ) + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + protocol = forms.MultipleChoiceField( + choices=FHRPGroupProtocolChoices, + required=False, + widget=StaticSelectMultiple() + ) + group_id = forms.IntegerField( + min_value=0, + required=False, + label='Group ID' + ) + auth_type = forms.MultipleChoiceField( + choices=FHRPGroupAuthTypeChoices, + required=False, + widget=StaticSelectMultiple(), + label='Authentication type' + ) + auth_key = forms.CharField( + required=False, + label='Authentication key' + ) + tag = TagFilterField(model) + + class VLANGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): field_groups = [ ['q', 'tag'], diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index a9c8a0910..36a078071 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -4,17 +4,22 @@ from django.contrib.contenttypes.models import ContentType from dcim.models import Device, Interface, Location, Rack, Region, Site, SiteGroup from extras.forms import CustomFieldModelForm from extras.models import Tag +from ipam.choices import * from ipam.constants import * +from ipam.formfields import IPNetworkFormField from ipam.models import * from tenancy.forms import TenancyForm +from utilities.exceptions import PermissionsViolation from utilities.forms import ( - BootstrapMixin, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, - NumericArrayField, SlugField, StaticSelect, StaticSelectMultiple, + add_blank_choice, BootstrapMixin, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField, + DynamicModelMultipleChoiceField, NumericArrayField, SlugField, StaticSelect, StaticSelectMultiple, ) from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface __all__ = ( 'AggregateForm', + 'FHRPGroupForm', + 'FHRPGroupAssignmentForm', 'IPAddressAssignForm', 'IPAddressBulkAddForm', 'IPAddressForm', @@ -472,6 +477,76 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form): ) +class FHRPGroupForm(BootstrapMixin, CustomFieldModelForm): + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + # Optionally create a new IPAddress along with the NHRPGroup + ip_vrf = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF' + ) + ip_address = IPNetworkFormField( + required=False, + label='Address' + ) + ip_status = forms.ChoiceField( + choices=add_blank_choice(IPAddressStatusChoices), + required=False, + label='Status' + ) + + class Meta: + model = FHRPGroup + fields = ( + 'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'ip_vrf', 'ip_address', 'ip_status', 'tags', + ) + fieldsets = ( + ('FHRP Group', ('protocol', 'group_id', 'description', 'tags')), + ('Authentication', ('auth_type', 'auth_key')), + ('Virtual IP Address', ('ip_vrf', 'ip_address', 'ip_status')) + ) + + def save(self, *args, **kwargs): + instance = super().save(*args, **kwargs) + + # Check if we need to create a new IPAddress for the group + if self.cleaned_data.get('ip_address'): + ipaddress = IPAddress( + vrf=self.cleaned_data['ip_vrf'], + address=self.cleaned_data['ip_address'], + status=self.cleaned_data['ip_status'], + assigned_object=instance + ) + ipaddress.role = { + FHRPGroupProtocolChoices.PROTOCOL_VRRP2: IPAddressRoleChoices.ROLE_VRRP, + FHRPGroupProtocolChoices.PROTOCOL_VRRP3: IPAddressRoleChoices.ROLE_VRRP, + FHRPGroupProtocolChoices.PROTOCOL_HSRP: IPAddressRoleChoices.ROLE_HSRP, + FHRPGroupProtocolChoices.PROTOCOL_GLBP: IPAddressRoleChoices.ROLE_GLBP, + FHRPGroupProtocolChoices.PROTOCOL_CARP: IPAddressRoleChoices.ROLE_CARP, + }[self.cleaned_data['protocol']] + ipaddress.save() + + # Check that the new IPAddress conforms with any assigned object-level permissions + if not IPAddress.objects.filter(pk=ipaddress.pk).first(): + raise PermissionsViolation() + + return instance + + +class FHRPGroupAssignmentForm(BootstrapMixin, forms.ModelForm): + group = DynamicModelChoiceField( + queryset=FHRPGroup.objects.all() + ) + + class Meta: + model = FHRPGroupAssignment + fields = ('group', 'priority') + + class VLANGroupForm(BootstrapMixin, CustomFieldModelForm): scope_type = ContentTypeChoiceField( queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), diff --git a/netbox/ipam/graphql/schema.py b/netbox/ipam/graphql/schema.py index 58909e57f..8c7830a24 100644 --- a/netbox/ipam/graphql/schema.py +++ b/netbox/ipam/graphql/schema.py @@ -29,6 +29,12 @@ class IPAMQuery(graphene.ObjectType): service = ObjectField(ServiceType) service_list = ObjectListField(ServiceType) + fhrp_group = ObjectField(FHRPGroupType) + fhrp_group_list = ObjectListField(FHRPGroupType) + + fhrp_group_assignment = ObjectField(FHRPGroupAssignmentType) + fhrp_group_assignment_list = ObjectListField(FHRPGroupAssignmentType) + vlan = ObjectField(VLANType) vlan_list = ObjectListField(VLANType) diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index c822dab6b..8e4119266 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -3,6 +3,8 @@ from netbox.graphql.types import OrganizationalObjectType, PrimaryObjectType __all__ = ( 'AggregateType', + 'FHRPGroupType', + 'FHRPGroupAssignmentType', 'IPAddressType', 'IPRangeType', 'PrefixType', @@ -24,6 +26,25 @@ class AggregateType(PrimaryObjectType): filterset_class = filtersets.AggregateFilterSet +class FHRPGroupType(PrimaryObjectType): + + class Meta: + model = models.FHRPGroup + fields = '__all__' + filterset_class = filtersets.FHRPGroupFilterSet + + def resolve_auth_type(self, info): + return self.auth_type or None + + +class FHRPGroupAssignmentType(PrimaryObjectType): + + class Meta: + model = models.FHRPGroupAssignment + fields = '__all__' + filterset_class = filtersets.FHRPGroupAssignmentFilterSet + + class IPAddressType(PrimaryObjectType): class Meta: diff --git a/netbox/ipam/migrations/0052_fhrpgroup.py b/netbox/ipam/migrations/0052_fhrpgroup.py new file mode 100644 index 000000000..f61191a7e --- /dev/null +++ b/netbox/ipam/migrations/0052_fhrpgroup.py @@ -0,0 +1,57 @@ +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0064_configrevision'), + ('contenttypes', '0002_remove_content_type_name'), + ('ipam', '0051_extend_tag_support'), + ] + + operations = [ + migrations.CreateModel( + name='FHRPGroup', + fields=[ + ('created', models.DateField(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=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('group_id', models.PositiveSmallIntegerField()), + ('protocol', models.CharField(max_length=50)), + ('auth_type', models.CharField(blank=True, max_length=50)), + ('auth_key', models.CharField(blank=True, max_length=255)), + ('description', models.CharField(blank=True, max_length=200)), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'verbose_name': 'FHRP group', + 'ordering': ['protocol', 'group_id', 'pk'], + }, + ), + migrations.AlterField( + model_name='ipaddress', + name='assigned_object_type', + field=models.ForeignKey(blank=True, limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'dcim'), ('model', 'interface')), models.Q(('app_label', 'ipam'), ('model', 'fhrpgroup')), models.Q(('app_label', 'virtualization'), ('model', 'vminterface')), _connector='OR')), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype'), + ), + migrations.CreateModel( + name='FHRPGroupAssignment', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('object_id', models.PositiveIntegerField()), + ('priority', models.PositiveSmallIntegerField(blank=True)), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='ipam.fhrpgroup')), + ], + options={ + 'verbose_name': 'FHRP group assignment', + 'ordering': ('priority', 'pk'), + 'unique_together': {('content_type', 'object_id', 'group')}, + }, + ), + ] diff --git a/netbox/ipam/models/__init__.py b/netbox/ipam/models/__init__.py index cb8b4b932..9747bcfb0 100644 --- a/netbox/ipam/models/__init__.py +++ b/netbox/ipam/models/__init__.py @@ -1,3 +1,4 @@ +from .fhrp import * from .ip import * from .services import * from .vlans import * @@ -7,6 +8,8 @@ __all__ = ( 'Aggregate', 'IPAddress', 'IPRange', + 'FHRPGroup', + 'FHRPGroupAssignment', 'Prefix', 'RIR', 'Role', diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py new file mode 100644 index 000000000..c108032b4 --- /dev/null +++ b/netbox/ipam/models/fhrp.py @@ -0,0 +1,92 @@ +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.models import ContentType +from django.db import models +from django.urls import reverse + +from extras.utils import extras_features +from netbox.models import ChangeLoggedModel, PrimaryModel +from ipam.choices import * +from utilities.querysets import RestrictedQuerySet + +__all__ = ( + 'FHRPGroup', + 'FHRPGroupAssignment', +) + + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class FHRPGroup(PrimaryModel): + """ + A grouping of next hope resolution protocol (FHRP) peers. (For instance, VRRP or HSRP.) + """ + group_id = models.PositiveSmallIntegerField( + verbose_name='Group ID' + ) + protocol = models.CharField( + max_length=50, + choices=FHRPGroupProtocolChoices + ) + auth_type = models.CharField( + max_length=50, + choices=FHRPGroupAuthTypeChoices, + blank=True, + verbose_name='Authentication type' + ) + auth_key = models.CharField( + max_length=255, + blank=True, + verbose_name='Authentication key' + ) + description = models.CharField( + max_length=200, + blank=True + ) + ip_addresses = GenericRelation( + to='ipam.IPAddress', + content_type_field='assigned_object_type', + object_id_field='assigned_object_id', + related_query_name='nhrp_group' + ) + + objects = RestrictedQuerySet.as_manager() + + clone_fields = [ + 'protocol', 'auth_type', 'auth_key' + ] + + class Meta: + ordering = ['protocol', 'group_id', 'pk'] + verbose_name = 'FHRP group' + + def __str__(self): + return f'{self.get_protocol_display()} group {self.group_id}' + + def get_absolute_url(self): + return reverse('ipam:fhrpgroup', args=[self.pk]) + + +@extras_features('webhooks') +class FHRPGroupAssignment(ChangeLoggedModel): + content_type = models.ForeignKey( + to=ContentType, + on_delete=models.CASCADE + ) + object_id = models.PositiveIntegerField() + object = GenericForeignKey( + ct_field='content_type', + fk_field='object_id' + ) + group = models.ForeignKey( + to='ipam.FHRPGroup', + on_delete=models.CASCADE + ) + priority = models.PositiveSmallIntegerField( + blank=True, + ) + + objects = RestrictedQuerySet.as_manager() + + class Meta: + ordering = ('priority', 'pk') + unique_together = ('content_type', 'object_id', 'group') + verbose_name = 'FHRP group assignment' diff --git a/netbox/ipam/tables/__init__.py b/netbox/ipam/tables/__init__.py index a280eac1b..6f429e27d 100644 --- a/netbox/ipam/tables/__init__.py +++ b/netbox/ipam/tables/__init__.py @@ -1,3 +1,4 @@ +from .fhrp import * from .ip import * from .services import * from .vlans import * diff --git a/netbox/ipam/tables/fhrp.py b/netbox/ipam/tables/fhrp.py new file mode 100644 index 000000000..e3411cd7e --- /dev/null +++ b/netbox/ipam/tables/fhrp.py @@ -0,0 +1,64 @@ +import django_tables2 as tables + +from utilities.tables import ( + BaseTable, ContentTypeColumn, MarkdownColumn, TagColumn, ToggleColumn, +) +from ipam.models import * + +__all__ = ( + 'FHRPGroupTable', + 'FHRPGroupAssignmentTable', +) + + +IPADDRESSES = """ +{% for ip in record.ip_addresses.all %} + {{ ip }}
+{% endfor %} +""" + + +class FHRPGroupTable(BaseTable): + pk = ToggleColumn() + group_id = tables.Column( + linkify=True + ) + comments = MarkdownColumn() + ip_addresses = tables.TemplateColumn( + template_code=IPADDRESSES, + orderable=False, + verbose_name='IP Addresses' + ) + member_count = tables.Column( + verbose_name='Members' + ) + tags = TagColumn( + url_name='ipam:fhrpgroup_list' + ) + + class Meta(BaseTable.Meta): + model = FHRPGroup + fields = ( + 'pk', 'group_id', 'protocol', 'auth_type', 'auth_key', 'description', 'ip_addresses', 'member_count', + 'tags', + ) + default_columns = ('pk', 'group_id', 'protocol', 'auth_type', 'description', 'ip_addresses', 'member_count') + + +class FHRPGroupAssignmentTable(BaseTable): + pk = ToggleColumn() + content_type = ContentTypeColumn( + verbose_name='Object Type' + ) + object = tables.Column( + linkify=True, + orderable=False + ) + group = tables.Column( + linkify=True + ) + + class Meta(BaseTable.Meta): + model = FHRPGroupAssignment + fields = ('pk', 'content_type', 'object', 'group', 'priority') + default_columns = ('pk', 'content_type', 'object', 'group', 'priority') diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index a2a0c67b1..a35bb7b78 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -11,7 +11,7 @@ from ipam.models import * __all__ = ( 'AggregateTable', - 'InterfaceIPAddressTable', + 'AssignedIPAddressesTable', 'IPAddressAssignTable', 'IPAddressTable', 'IPRangeTable', @@ -359,9 +359,9 @@ class IPAddressAssignTable(BaseTable): orderable = False -class InterfaceIPAddressTable(BaseTable): +class AssignedIPAddressesTable(BaseTable): """ - List IP addresses assigned to a specific Interface. + List IP addresses assigned to an object. """ address = tables.Column( linkify=True, diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 5ba45b7fd..f3796f781 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -491,6 +491,47 @@ class IPAddressTest(APIViewTestCases.APIViewTestCase): IPAddress.objects.bulk_create(ip_addresses) +class FHRPGroupTest(APIViewTestCases.APIViewTestCase): + model = FHRPGroup + brief_fields = ['display', 'group_id', 'id', 'protocol', 'url'] + bulk_update_data = { + 'protocol': FHRPGroupProtocolChoices.PROTOCOL_GLBP, + 'group_id': 200, + 'auth_type': FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5, + 'auth_key': 'foobarbaz999', + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + + fhrp_groups = ( + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=10, auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_PLAINTEXT, auth_key='foobar123'), + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP3, group_id=20, auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5, auth_key='foobar123'), + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_HSRP, group_id=30), + ) + FHRPGroup.objects.bulk_create(fhrp_groups) + + cls.create_data = [ + { + 'protocol': FHRPGroupProtocolChoices.PROTOCOL_VRRP2, + 'group_id': 110, + 'auth_type': FHRPGroupAuthTypeChoices.AUTHENTICATION_PLAINTEXT, + 'auth_key': 'foobar123', + }, + { + 'protocol': FHRPGroupProtocolChoices.PROTOCOL_VRRP3, + 'group_id': 120, + 'auth_type': FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5, + 'auth_key': 'barfoo456', + }, + { + 'protocol': FHRPGroupProtocolChoices.PROTOCOL_GLBP, + 'group_id': 130, + }, + ] + + class VLANGroupTest(APIViewTestCases.APIViewTestCase): model = VLANGroup brief_fields = ['display', 'id', 'name', 'slug', 'url', 'vlan_count'] diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index ff9dbfece..e5df58a2b 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -795,6 +795,33 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) +class FHRPGroupTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = FHRPGroup.objects.all() + filterset = FHRPGroupFilterSet + + @classmethod + def setUpTestData(cls): + + fhrp_groups = ( + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=10, auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_PLAINTEXT, auth_key='foobar123'), + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP3, group_id=20, auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5, auth_key='foobar123'), + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_HSRP, group_id=30), + ) + FHRPGroup.objects.bulk_create(fhrp_groups) + + def test_protocol(self): + params = {'protocol': [FHRPGroupProtocolChoices.PROTOCOL_VRRP2, FHRPGroupProtocolChoices.PROTOCOL_VRRP3]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_group_id(self): + params = {'group_id': [10, 20]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_auth_type(self): + params = {'auth_type': [FHRPGroupAuthTypeChoices.AUTHENTICATION_PLAINTEXT, FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = VLANGroup.objects.all() filterset = VLANGroupFilterSet diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 5440efcb6..da4627ca8 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -372,6 +372,41 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase): } +class FHRPGroupTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = FHRPGroup + + @classmethod + def setUpTestData(cls): + + FHRPGroup.objects.bulk_create(( + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=10, auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_PLAINTEXT, auth_key='foobar123'), + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP3, group_id=20, auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5, auth_key='foobar123'), + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_HSRP, group_id=30), + )) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'protocol': FHRPGroupProtocolChoices.PROTOCOL_VRRP2, + 'group_id': 99, + 'auth_type': FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5, + 'auth_key': 'abc123def456', + 'description': 'Blah blah blah', + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "protocol,group_id,auth_type,auth_key,description", + "vrrp2,40,plaintext,foobar123,Foo", + "vrrp3,50,md5,foobar123,Bar", + "hsrp,60,,,", + ) + + cls.bulk_edit_data = { + 'protocol': FHRPGroupProtocolChoices.PROTOCOL_CARP, + } + + class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = VLANGroup diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 9d9a846bf..ccce246cd 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -107,6 +107,23 @@ urlpatterns = [ path('ip-addresses//edit/', views.IPAddressEditView.as_view(), name='ipaddress_edit'), path('ip-addresses//delete/', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'), + # FHRP groups + path('fhrp-groups/', views.FHRPGroupListView.as_view(), name='fhrpgroup_list'), + path('fhrp-groups/add/', views.FHRPGroupEditView.as_view(), name='fhrpgroup_add'), + path('fhrp-groups/import/', views.FHRPGroupBulkImportView.as_view(), name='fhrpgroup_import'), + path('fhrp-groups/edit/', views.FHRPGroupBulkEditView.as_view(), name='fhrpgroup_bulk_edit'), + path('fhrp-groups/delete/', views.FHRPGroupBulkDeleteView.as_view(), name='fhrpgroup_bulk_delete'), + path('fhrp-groups//', views.FHRPGroupView.as_view(), name='fhrpgroup'), + path('fhrp-groups//edit/', views.FHRPGroupEditView.as_view(), name='fhrpgroup_edit'), + path('fhrp-groups//delete/', views.FHRPGroupDeleteView.as_view(), name='fhrpgroup_delete'), + path('fhrp-groups//changelog/', ObjectChangeLogView.as_view(), name='fhrpgroup_changelog', kwargs={'model': FHRPGroup}), + path('fhrp-groups//journal/', ObjectJournalView.as_view(), name='fhrpgroup_journal', kwargs={'model': FHRPGroup}), + + # FHRP group assignments + path('fhrp-group-assignments/add/', views.FHRPGroupAssignmentEditView.as_view(), name='fhrpgroupassignment_add'), + path('fhrp-group-assignments//edit/', views.FHRPGroupAssignmentEditView.as_view(), name='fhrpgroupassignment_edit'), + path('fhrp-group-assignments//delete/', views.FHRPGroupAssignmentDeleteView.as_view(), name='fhrpgroupassignment_delete'), + # VLAN groups path('vlan-groups/', views.VLANGroupListView.as_view(), name='vlangroup_list'), path('vlan-groups/add/', views.VLANGroupEditView.as_view(), name='vlangroup_add'), diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index c24a80124..b4864577d 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -1,10 +1,11 @@ +from django.contrib.contenttypes.models import ContentType from django.db.models import Prefetch from django.db.models.expressions import RawSQL +from django.http import Http404 from django.shortcuts import get_object_or_404, redirect, render from dcim.models import Device, Interface from netbox.views import generic -from utilities.forms import TableConfigForm from utilities.tables import paginate_table from utilities.utils import count_related from virtualization.models import VirtualMachine, VMInterface @@ -825,6 +826,101 @@ class VLANGroupBulkDeleteView(generic.BulkDeleteView): table = tables.VLANGroupTable +# +# FHRP groups +# + +class FHRPGroupListView(generic.ObjectListView): + queryset = FHRPGroup.objects.annotate( + member_count=count_related(FHRPGroupAssignment, 'group') + ) + filterset = filtersets.FHRPGroupFilterSet + filterset_form = forms.FHRPGroupFilterForm + table = tables.FHRPGroupTable + + +class FHRPGroupView(generic.ObjectView): + queryset = FHRPGroup.objects.all() + + def get_extra_context(self, request, instance): + # Get assigned IP addresses + ipaddress_table = tables.AssignedIPAddressesTable( + data=instance.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'), + orderable=False + ) + + group_assignments = FHRPGroupAssignment.objects.restrict(request.user, 'view').filter( + group=instance + ) + members_table = tables.FHRPGroupAssignmentTable(group_assignments) + members_table.columns.hide('group') + paginate_table(members_table, request) + + return { + 'ipaddress_table': ipaddress_table, + 'members_table': members_table, + 'member_count': FHRPGroupAssignment.objects.filter(group=instance).count(), + } + + +class FHRPGroupEditView(generic.ObjectEditView): + queryset = FHRPGroup.objects.all() + model_form = forms.FHRPGroupForm + + +class FHRPGroupDeleteView(generic.ObjectDeleteView): + queryset = FHRPGroup.objects.all() + + +class FHRPGroupBulkImportView(generic.BulkImportView): + queryset = FHRPGroup.objects.all() + model_form = forms.FHRPGroupCSVForm + table = tables.FHRPGroupTable + + +class FHRPGroupBulkEditView(generic.BulkEditView): + queryset = FHRPGroup.objects.all() + filterset = filtersets.FHRPGroupFilterSet + table = tables.FHRPGroupTable + form = forms.FHRPGroupBulkEditForm + + +class FHRPGroupBulkDeleteView(generic.BulkDeleteView): + queryset = FHRPGroup.objects.all() + filterset = filtersets.FHRPGroupFilterSet + table = tables.FHRPGroupTable + + +# +# FHRP group assignments +# + +class FHRPGroupAssignmentEditView(generic.ObjectEditView): + queryset = FHRPGroupAssignment.objects.all() + model_form = forms.FHRPGroupAssignmentForm + + def alter_obj(self, instance, request, args, kwargs): + if not instance.pk: + # Assign the object based on URL kwargs + try: + app_label, model = request.GET.get('content_type').split('.') + except (AttributeError, ValueError): + raise Http404("Content type not specified") + content_type = get_object_or_404(ContentType, app_label=app_label, model=model) + instance.object = get_object_or_404(content_type.model_class(), pk=request.GET.get('object_id')) + return instance + + def get_return_url(self, request, obj=None): + return obj.object.get_absolute_url() if obj else super().get_return_url(request) + + +class FHRPGroupAssignmentDeleteView(generic.ObjectDeleteView): + queryset = FHRPGroupAssignment.objects.all() + + def get_return_url(self, request, obj=None): + return obj.object.get_absolute_url() if obj else super().get_return_url(request) + + # # VLANs # diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index 993c5e171..1d06f1d5c 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -251,8 +251,9 @@ IPAM_MENU = Menu( ), ), MenuGroup( - label='Services', + label='Other', items=( + get_model_item('ipam', 'fhrpgroup', 'FHRP Groups'), get_model_item('ipam', 'service', 'Services', actions=['import']), ), ), diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 5851b3aeb..a6dc1a901 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -440,6 +440,42 @@ {% endif %} +
+
NHRP Groups
+
+ + + + + + + + + {% for assignment in object.fhrp_group_assignments.all %} + + + + + {% empty %} + + + + {% endfor %} + +
GroupPriority
+ {{ assignment.group }} + + {{ assignment.priority }} +
None
+
+ {% if perms.ipam.add_fhrpgroupassignment %} + + {% endif %} +
{% plugin_right_page object %} diff --git a/netbox/templates/inc/panels/nhrp_groups.html b/netbox/templates/inc/panels/nhrp_groups.html new file mode 100644 index 000000000..223354441 --- /dev/null +++ b/netbox/templates/inc/panels/nhrp_groups.html @@ -0,0 +1,49 @@ +{% load helpers %} + +
+
Contacts
+
+ {% with fhrp_groups=object.fhrp_group_assignments.all %} + {% if contacts.exists %} + + + + + + + + {% for contact in contacts %} + + + + + + + {% endfor %} +
ProtocolGroup IDPriority
+ {{ contact.contact }} + {{ contact.role|placeholder }}{{ contact.get_priority_display|placeholder }} + {% if perms.tenancy.change_contactassignment %} + + + + {% endif %} + {% if perms.tenancy.delete_contactassignment %} + + + + {% endif %} +
+ {% else %} +
None
+ {% endif %} + {% endwith %} +
+ {% if perms.tenancy.add_contactassignment %} + + {% endif %} +
diff --git a/netbox/templates/ipam/fhrpgroup.html b/netbox/templates/ipam/fhrpgroup.html new file mode 100644 index 000000000..c4e3eadc3 --- /dev/null +++ b/netbox/templates/ipam/fhrpgroup.html @@ -0,0 +1,82 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load render_table from django_tables2 %} + +{% block breadcrumbs %} + {{ block.super }} + +{% endblock breadcrumbs %} + +{% block content %} +
+
+
+
FHRP Group
+
+ + + + + + + + + + + + + + + + + +
Protocol{{ object.get_protocol_display }}
Group ID{{ object.group_id }}
Description{{ object.description|placeholder }}
Members{{ member_count }}
+
+
+ {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %} +
+
+
+
Authentication
+
+ + + + + + + + + +
Authentication Type{{ object.get_auth_type_display|placeholder }}
Authentication Key{{ object.auth_key|placeholder }}
+
+
+ {% include 'inc/panels/custom_fields.html' %} + {% plugin_right_page object %} +
+
+
+
+
+
IP Addresses
+
+ {% if ipaddress_table.rows %} + {% render_table ipaddress_table 'inc/table.html' %} + {% else %} +
None
+ {% endif %} +
+
+
+
Members
+
+ {% include 'inc/table.html' with table=members_table %} +
+
+ {% include 'inc/paginator.html' with paginator=members_table.paginator page=members_table.page %} + {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 31782bdd7..c39f4398a 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -73,12 +73,14 @@ Assignment - {% if object.assigned_object %} - {{ object.assigned_object.parent_object }} / - {{ object.assigned_object }} - {% else %} - + {% if object.assigned_object %} + {% if object.assigned_object.parent_object %} + {{ object.assigned_object.parent_object }} / {% endif %} + {{ object.assigned_object }} + {% else %} + + {% endif %} diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index db2404546..3567b86c5 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -398,6 +398,12 @@ class VMInterface(PrimaryModel, BaseInterface): object_id_field='assigned_object_id', related_query_name='vminterface' ) + fhrp_group_assignments = GenericRelation( + to='ipam.FHRPGroupAssignment', + content_type_field='content_type', + object_id_field='object_id', + related_query_name='vminterface' + ) objects = RestrictedQuerySet.as_manager() diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 2294d2c38..5cb4f133a 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -8,7 +8,7 @@ from dcim.models import Device from dcim.tables import DeviceTable from extras.views import ObjectConfigContextView from ipam.models import IPAddress, Service -from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable +from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable from netbox.views import generic from utilities.tables import paginate_table from utilities.utils import count_related @@ -421,7 +421,7 @@ class VMInterfaceView(generic.ObjectView): def get_extra_context(self, request, instance): # Get assigned IP addresses - ipaddress_table = InterfaceIPAddressTable( + ipaddress_table = AssignedIPAddressesTable( data=instance.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'), orderable=False )