Jeremy Stretch 2 лет назад
Родитель
Сommit
8db1093fdc

+ 1 - 1
docs/features/vpn-tunnels.md

@@ -1,6 +1,6 @@
 # Tunnels
 # 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.
+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. For convenient organization, tunnels may be assigned to user-defined groups.
 
 
 ```mermaid
 ```mermaid
 flowchart TD
 flowchart TD

+ 7 - 5
docs/models/vpn/tunnel.md

@@ -14,15 +14,17 @@ A unique name assigned to the tunnel for identification.
 
 
 The operational status of the tunnel. By default, the following statuses are available:
 The operational status of the tunnel. By default, the following statuses are available:
 
 
-| Name           |
-|----------------|
-| Planned        |
-| Active         |
-| Disabled       |
+* Planned
+* Active
+* Disabled
 
 
 !!! tip "Custom tunnel statuses"
 !!! 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.
     Additional tunnel statuses may be defined by setting `Tunnel.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
 
 
+### Group
+
+The [administrative group](./tunnelgroup.md) to which this tunnel is assigned (optional).
+
 ### Encapsulation
 ### Encapsulation
 
 
 The encapsulation protocol or technique employed to effect the tunnel. NetBox supports GRE, IP-in-IP, and IPSec encapsulations.
 The encapsulation protocol or technique employed to effect the tunnel. NetBox supports GRE, IP-in-IP, and IPSec encapsulations.

+ 13 - 0
docs/models/vpn/tunnelgroup.md

@@ -0,0 +1,13 @@
+# Tunnel Group
+
+[Tunnels](./tunnel.md) can be arranged into administrative groups for organization. For example, you might crete a group to manage all peer-to-peer tunnels inside a mesh network. The assignment of a tunnel to a group is optional.
+
+## Fields
+
+### Name
+
+A unique human-friendly name.
+
+### Slug
+
+A unique URL-friendly identifier. (This value can be used for filtering.)

+ 1 - 1
netbox/dcim/tables/template_code.py

@@ -361,7 +361,7 @@ INTERFACE_BUTTONS = """
     {% endif %}
     {% endif %}
 {% elif record.type == 'virtual' %}
 {% elif record.type == 'virtual' %}
     {% if perms.vpn.add_tunnel and not record.tunnel_termination %}
     {% if perms.vpn.add_tunnel and not record.tunnel_termination %}
-        <a href="{% url 'vpn:tunnel_add' %}?termination1_type=dcim.device&termination1_parent={{ record.device.pk }}&termination1_interface={{ record.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}" title="Create a tunnel" class="btn btn-success btn-sm">
+        <a href="{% url 'vpn:tunnel_add' %}?termination1_type=dcim.device&termination1_parent={{ record.device.pk }}&termination1_termination={{ record.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}" title="Create a tunnel" class="btn btn-success btn-sm">
             <i class="mdi mdi-tunnel-outline" aria-hidden="true"></i>
             <i class="mdi mdi-tunnel-outline" aria-hidden="true"></i>
         </a>
         </a>
     {% elif perms.vpn.delete_tunneltermination and record.tunnel_termination %}
     {% elif perms.vpn.delete_tunneltermination and record.tunnel_termination %}

+ 1 - 0
netbox/netbox/navigation/menu.py

@@ -203,6 +203,7 @@ VPN_MENU = Menu(
             label=_('Tunnels'),
             label=_('Tunnels'),
             items=(
             items=(
                 get_model_item('vpn', 'tunnel', _('Tunnels')),
                 get_model_item('vpn', 'tunnel', _('Tunnels')),
+                get_model_item('vpn', 'tunnelgroup', _('Tunnel Groups')),
                 get_model_item('vpn', 'tunneltermination', _('Tunnel Terminations')),
                 get_model_item('vpn', 'tunneltermination', _('Tunnel Terminations')),
             ),
             ),
         ),
         ),

+ 4 - 0
netbox/templates/vpn/tunnel.html

@@ -26,6 +26,10 @@
               <th scope="row">{% trans "Status" %}</th>
               <th scope="row">{% trans "Status" %}</th>
               <td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
               <td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
             </tr>
             </tr>
+            <tr>
+              <th scope="row">{% trans "Group" %}</th>
+              <td>{{ object.group|linkify|placeholder }}</td>
+            </tr>
             <tr>
             <tr>
               <th scope="row">{% trans "Description" %}</th>
               <th scope="row">{% trans "Description" %}</th>
               <td>{{ object.description|placeholder }}</td>
               <td>{{ object.description|placeholder }}</td>

+ 53 - 0
netbox/templates/vpn/tunnelgroup.html

@@ -0,0 +1,53 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+{% load render_table from django_tables2 %}
+{% load i18n %}
+
+{% block breadcrumbs %}
+  <li class="breadcrumb-item"><a href="{% url 'vpn:tunnelgroup_list' %}">{% trans "Tunnel Groups" %}</a></li>
+{% endblock %}
+
+{% block extra_controls %}
+  {% if perms.vpn.add_tunnel %}
+    <a href="{% url 'vpn:tunnel_add' %}?group={{ object.pk }}" class="btn btn-sm btn-primary">
+      <span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add Tunnel" %}
+    </a>
+  {% endif %}
+{% endblock extra_controls %}
+
+{% block content %}
+  <div class="row mb-3">
+    <div class="col col-md-6">
+      <div class="card">
+        <h5 class="card-header">
+          {% trans "Tunnel Group" %}
+        </h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">{% trans "Name" %}</th>
+              <td>{{ object.name }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Description" %}</th>
+              <td>{{ object.description|placeholder }}</td>
+            </tr>
+          </table>
+        </div>
+      </div>
+      {% include 'inc/panels/tags.html' %}
+      {% plugin_left_page object %}
+    </div>
+    <div class="col col-md-6">
+      {% include 'inc/panels/related_objects.html' %}
+      {% include 'inc/panels/custom_fields.html' %}
+      {% plugin_right_page object %}
+    </div>
+  </div>
+  <div class="row mb-3">
+    <div class="col col-md-12">
+      {% plugin_full_width_page object %}
+    </div>
+  </div>
+{% endblock %}

+ 14 - 0
netbox/vpn/api/nested_serializers.py

@@ -1,3 +1,4 @@
+from drf_spectacular.utils import extend_schema_serializer
 from rest_framework import serializers
 from rest_framework import serializers
 
 
 from netbox.api.serializers import WritableNestedSerializer
 from netbox.api.serializers import WritableNestedSerializer
@@ -11,11 +12,24 @@ __all__ = (
     'NestedIPSecProposalSerializer',
     'NestedIPSecProposalSerializer',
     'NestedL2VPNSerializer',
     'NestedL2VPNSerializer',
     'NestedL2VPNTerminationSerializer',
     'NestedL2VPNTerminationSerializer',
+    'NestedTunnelGroupSerializer',
     'NestedTunnelSerializer',
     'NestedTunnelSerializer',
     'NestedTunnelTerminationSerializer',
     'NestedTunnelTerminationSerializer',
 )
 )
 
 
 
 
+@extend_schema_serializer(
+    exclude_fields=('tunnel_count',),
+)
+class NestedTunnelGroupSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='vpn-api:tunnelgroup-detail')
+    tunnel_count = serializers.IntegerField(read_only=True)
+
+    class Meta:
+        model = models.TunnelGroup
+        fields = ['id', 'url', 'display', 'name', 'slug', 'tunnel_count']
+
+
 class NestedTunnelSerializer(WritableNestedSerializer):
 class NestedTunnelSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(
     url = serializers.HyperlinkedIdentityField(
         view_name='vpn-api:tunnel-detail'
         view_name='vpn-api:tunnel-detail'

+ 15 - 1
netbox/vpn/api/serializers.py

@@ -21,11 +21,24 @@ __all__ = (
     'IPSecProposalSerializer',
     'IPSecProposalSerializer',
     'L2VPNSerializer',
     'L2VPNSerializer',
     'L2VPNTerminationSerializer',
     'L2VPNTerminationSerializer',
+    'TunnelGroupSerializer',
     'TunnelSerializer',
     'TunnelSerializer',
     'TunnelTerminationSerializer',
     'TunnelTerminationSerializer',
 )
 )
 
 
 
 
+class TunnelGroupSerializer(NetBoxModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='vpn-api:tunnelgroup-detail')
+    tunnel_count = serializers.IntegerField(read_only=True)
+
+    class Meta:
+        model = TunnelGroup
+        fields = [
+            'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
+            'tunnel_count',
+        ]
+
+
 class TunnelSerializer(NetBoxModelSerializer):
 class TunnelSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(
     url = serializers.HyperlinkedIdentityField(
         view_name='vpn-api:tunnel-detail'
         view_name='vpn-api:tunnel-detail'
@@ -33,6 +46,7 @@ class TunnelSerializer(NetBoxModelSerializer):
     status = ChoiceField(
     status = ChoiceField(
         choices=TunnelStatusChoices
         choices=TunnelStatusChoices
     )
     )
+    group = NestedTunnelGroupSerializer()
     encapsulation = ChoiceField(
     encapsulation = ChoiceField(
         choices=TunnelEncapsulationChoices
         choices=TunnelEncapsulationChoices
     )
     )
@@ -48,7 +62,7 @@ class TunnelSerializer(NetBoxModelSerializer):
     class Meta:
     class Meta:
         model = Tunnel
         model = Tunnel
         fields = (
         fields = (
-            'id', 'url', 'display', 'name', 'status', 'encapsulation', 'ipsec_profile', 'tenant', 'tunnel_id',
+            'id', 'url', 'display', 'name', 'status', 'group', 'encapsulation', 'ipsec_profile', 'tenant', 'tunnel_id',
             'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
             'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
         )
         )
 
 

+ 1 - 0
netbox/vpn/api/urls.py

@@ -8,6 +8,7 @@ router.register('ike-proposals', views.IKEProposalViewSet)
 router.register('ipsec-policies', views.IPSecPolicyViewSet)
 router.register('ipsec-policies', views.IPSecPolicyViewSet)
 router.register('ipsec-proposals', views.IPSecProposalViewSet)
 router.register('ipsec-proposals', views.IPSecProposalViewSet)
 router.register('ipsec-profiles', views.IPSecProfileViewSet)
 router.register('ipsec-profiles', views.IPSecProfileViewSet)
+router.register('tunnel-groups', views.TunnelGroupViewSet)
 router.register('tunnels', views.TunnelViewSet)
 router.register('tunnels', views.TunnelViewSet)
 router.register('tunnel-terminations', views.TunnelTerminationViewSet)
 router.register('tunnel-terminations', views.TunnelTerminationViewSet)
 router.register('l2vpns', views.L2VPNViewSet)
 router.register('l2vpns', views.L2VPNViewSet)

+ 9 - 0
netbox/vpn/api/views.py

@@ -14,6 +14,7 @@ __all__ = (
     'IPSecProposalViewSet',
     'IPSecProposalViewSet',
     'L2VPNViewSet',
     'L2VPNViewSet',
     'L2VPNTerminationViewSet',
     'L2VPNTerminationViewSet',
+    'TunnelGroupViewSet',
     'TunnelTerminationViewSet',
     'TunnelTerminationViewSet',
     'TunnelViewSet',
     'TunnelViewSet',
     'VPNRootView',
     'VPNRootView',
@@ -32,6 +33,14 @@ class VPNRootView(APIRootView):
 # Viewsets
 # Viewsets
 #
 #
 
 
+class TunnelGroupViewSet(NetBoxModelViewSet):
+    queryset = TunnelGroup.objects.annotate(
+        tunnel_count=count_related(Tunnel, 'group')
+    )
+    serializer_class = serializers.TunnelGroupSerializer
+    filterset_class = filtersets.TunnelGroupFilterSet
+
+
 class TunnelViewSet(NetBoxModelViewSet):
 class TunnelViewSet(NetBoxModelViewSet):
     queryset = Tunnel.objects.prefetch_related('ipsec_profile', 'tenant').annotate(
     queryset = Tunnel.objects.prefetch_related('ipsec_profile', 'tenant').annotate(
         terminations_count=count_related(TunnelTermination, 'tunnel')
         terminations_count=count_related(TunnelTermination, 'tunnel')

+ 19 - 1
netbox/vpn/filtersets.py

@@ -4,7 +4,7 @@ from django.utils.translation import gettext as _
 
 
 from dcim.models import Device, Interface
 from dcim.models import Device, Interface
 from ipam.models import IPAddress, RouteTarget, VLAN
 from ipam.models import IPAddress, RouteTarget, VLAN
-from netbox.filtersets import NetBoxModelFilterSet
+from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet
 from tenancy.filtersets import TenancyFilterSet
 from tenancy.filtersets import TenancyFilterSet
 from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
 from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
 from virtualization.models import VirtualMachine, VMInterface
 from virtualization.models import VirtualMachine, VMInterface
@@ -20,14 +20,32 @@ __all__ = (
     'L2VPNFilterSet',
     'L2VPNFilterSet',
     'L2VPNTerminationFilterSet',
     'L2VPNTerminationFilterSet',
     'TunnelFilterSet',
     'TunnelFilterSet',
+    'TunnelGroupFilterSet',
     'TunnelTerminationFilterSet',
     'TunnelTerminationFilterSet',
 )
 )
 
 
 
 
+class TunnelGroupFilterSet(OrganizationalModelFilterSet):
+
+    class Meta:
+        model = TunnelGroup
+        fields = ['id', 'name', 'slug', 'description']
+
+
 class TunnelFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
 class TunnelFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
     status = django_filters.MultipleChoiceFilter(
     status = django_filters.MultipleChoiceFilter(
         choices=TunnelStatusChoices
         choices=TunnelStatusChoices
     )
     )
+    group_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=TunnelGroup.objects.all(),
+        label=_('Tunnel group (ID)'),
+    )
+    group = django_filters.ModelMultipleChoiceFilter(
+        field_name='group__slug',
+        queryset=TunnelGroup.objects.all(),
+        to_field_name='slug',
+        label=_('Tunnel group (slug)'),
+    )
     encapsulation = django_filters.MultipleChoiceFilter(
     encapsulation = django_filters.MultipleChoiceFilter(
         choices=TunnelEncapsulationChoices
         choices=TunnelEncapsulationChoices
     )
     )

+ 19 - 2
netbox/vpn/forms/bulk_edit.py

@@ -17,16 +17,33 @@ __all__ = (
     'L2VPNBulkEditForm',
     'L2VPNBulkEditForm',
     'L2VPNTerminationBulkEditForm',
     'L2VPNTerminationBulkEditForm',
     'TunnelBulkEditForm',
     'TunnelBulkEditForm',
+    'TunnelGroupBulkEditForm',
     'TunnelTerminationBulkEditForm',
     'TunnelTerminationBulkEditForm',
 )
 )
 
 
 
 
+class TunnelGroupBulkEditForm(NetBoxModelBulkEditForm):
+    description = forms.CharField(
+        label=_('Description'),
+        max_length=200,
+        required=False
+    )
+
+    model = TunnelGroup
+    nullable_fields = ('description',)
+
+
 class TunnelBulkEditForm(NetBoxModelBulkEditForm):
 class TunnelBulkEditForm(NetBoxModelBulkEditForm):
     status = forms.ChoiceField(
     status = forms.ChoiceField(
         label=_('Status'),
         label=_('Status'),
         choices=add_blank_choice(TunnelStatusChoices),
         choices=add_blank_choice(TunnelStatusChoices),
         required=False
         required=False
     )
     )
+    group = DynamicModelChoiceField(
+        queryset=TunnelGroup.objects.all(),
+        label=_('Tunnel group'),
+        required=False
+    )
     encapsulation = forms.ChoiceField(
     encapsulation = forms.ChoiceField(
         label=_('Encapsulation'),
         label=_('Encapsulation'),
         choices=add_blank_choice(TunnelEncapsulationChoices),
         choices=add_blank_choice(TunnelEncapsulationChoices),
@@ -55,12 +72,12 @@ class TunnelBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = Tunnel
     model = Tunnel
     fieldsets = (
     fieldsets = (
-        (_('Tunnel'), ('status', 'encapsulation', 'tunnel_id', 'description')),
+        (_('Tunnel'), ('status', 'group', 'encapsulation', 'tunnel_id', 'description')),
         (_('Security'), ('ipsec_profile',)),
         (_('Security'), ('ipsec_profile',)),
         (_('Tenancy'), ('tenant',)),
         (_('Tenancy'), ('tenant',)),
     )
     )
     nullable_fields = (
     nullable_fields = (
-        'ipsec_profile', 'tunnel_id', 'tenant', 'description', 'comments',
+        'group', 'ipsec_profile', 'tunnel_id', 'tenant', 'description', 'comments',
     )
     )
 
 
 
 

+ 18 - 3
netbox/vpn/forms/bulk_import.py

@@ -5,7 +5,7 @@ from dcim.models import Device, Interface
 from ipam.models import IPAddress, VLAN
 from ipam.models import IPAddress, VLAN
 from netbox.forms import NetBoxModelImportForm
 from netbox.forms import NetBoxModelImportForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
-from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField
+from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField, SlugField
 from virtualization.models import VirtualMachine, VMInterface
 from virtualization.models import VirtualMachine, VMInterface
 from vpn.choices import *
 from vpn.choices import *
 from vpn.models import *
 from vpn.models import *
@@ -19,16 +19,31 @@ __all__ = (
     'L2VPNImportForm',
     'L2VPNImportForm',
     'L2VPNTerminationImportForm',
     'L2VPNTerminationImportForm',
     'TunnelImportForm',
     'TunnelImportForm',
+    'TunnelGroupImportForm',
     'TunnelTerminationImportForm',
     'TunnelTerminationImportForm',
 )
 )
 
 
 
 
+class TunnelGroupImportForm(NetBoxModelImportForm):
+    slug = SlugField()
+
+    class Meta:
+        model = TunnelGroup
+        fields = ('name', 'slug', 'description', 'tags')
+
+
 class TunnelImportForm(NetBoxModelImportForm):
 class TunnelImportForm(NetBoxModelImportForm):
     status = CSVChoiceField(
     status = CSVChoiceField(
         label=_('Status'),
         label=_('Status'),
         choices=TunnelStatusChoices,
         choices=TunnelStatusChoices,
         help_text=_('Operational status')
         help_text=_('Operational status')
     )
     )
+    group = CSVModelChoiceField(
+        label=_('Tunnel group'),
+        queryset=TunnelGroup.objects.all(),
+        required=False,
+        to_field_name='name'
+    )
     encapsulation = CSVChoiceField(
     encapsulation = CSVChoiceField(
         label=_('Encapsulation'),
         label=_('Encapsulation'),
         choices=TunnelEncapsulationChoices,
         choices=TunnelEncapsulationChoices,
@@ -51,8 +66,8 @@ class TunnelImportForm(NetBoxModelImportForm):
     class Meta:
     class Meta:
         model = Tunnel
         model = Tunnel
         fields = (
         fields = (
-            'name', 'status', 'encapsulation', 'ipsec_profile', 'tenant', 'tunnel_id', 'description', 'comments',
-            'tags',
+            'name', 'status', 'group', 'encapsulation', 'ipsec_profile', 'tenant', 'tunnel_id', 'description',
+            'comments', 'tags',
         )
         )
 
 
 
 

+ 11 - 0
netbox/vpn/forms/filtersets.py

@@ -24,10 +24,16 @@ __all__ = (
     'L2VPNFilterForm',
     'L2VPNFilterForm',
     'L2VPNTerminationFilterForm',
     'L2VPNTerminationFilterForm',
     'TunnelFilterForm',
     'TunnelFilterForm',
+    'TunnelGroupFilterForm',
     'TunnelTerminationFilterForm',
     'TunnelTerminationFilterForm',
 )
 )
 
 
 
 
+class TunnelGroupFilterForm(NetBoxModelFilterSetForm):
+    model = TunnelGroup
+    tag = TagFilterField(model)
+
+
 class TunnelFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class TunnelFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = Tunnel
     model = Tunnel
     fieldsets = (
     fieldsets = (
@@ -41,6 +47,11 @@ class TunnelFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
         choices=TunnelStatusChoices,
         choices=TunnelStatusChoices,
         required=False
         required=False
     )
     )
+    group_id = DynamicModelMultipleChoiceField(
+        queryset=TunnelGroup.objects.all(),
+        required=False,
+        label=_('Tunnel group')
+    )
     encapsulation = forms.MultipleChoiceField(
     encapsulation = forms.MultipleChoiceField(
         label=_('Encapsulation'),
         label=_('Encapsulation'),
         choices=TunnelEncapsulationChoices,
         choices=TunnelEncapsulationChoices,

+ 23 - 3
netbox/vpn/forms/model_forms.py

@@ -23,11 +23,31 @@ __all__ = (
     'L2VPNTerminationForm',
     'L2VPNTerminationForm',
     'TunnelCreateForm',
     'TunnelCreateForm',
     'TunnelForm',
     'TunnelForm',
+    'TunnelGroupForm',
     'TunnelTerminationForm',
     'TunnelTerminationForm',
 )
 )
 
 
 
 
+class TunnelGroupForm(NetBoxModelForm):
+    slug = SlugField()
+
+    fieldsets = (
+        (_('Tunnel Group'), ('name', 'slug', 'description', 'tags')),
+    )
+
+    class Meta:
+        model = TunnelGroup
+        fields = [
+            'name', 'slug', 'description', 'tags',
+        ]
+
+
 class TunnelForm(TenancyForm, NetBoxModelForm):
 class TunnelForm(TenancyForm, NetBoxModelForm):
+    group = DynamicModelChoiceField(
+        queryset=TunnelGroup.objects.all(),
+        label=_('Tunnel Group'),
+        required=False
+    )
     ipsec_profile = DynamicModelChoiceField(
     ipsec_profile = DynamicModelChoiceField(
         queryset=IPSecProfile.objects.all(),
         queryset=IPSecProfile.objects.all(),
         label=_('IPSec Profile'),
         label=_('IPSec Profile'),
@@ -36,7 +56,7 @@ class TunnelForm(TenancyForm, NetBoxModelForm):
     comments = CommentField()
     comments = CommentField()
 
 
     fieldsets = (
     fieldsets = (
-        (_('Tunnel'), ('name', 'status', 'encapsulation', 'description', 'tunnel_id', 'tags')),
+        (_('Tunnel'), ('name', 'status', 'group', 'encapsulation', 'description', 'tunnel_id', 'tags')),
         (_('Security'), ('ipsec_profile',)),
         (_('Security'), ('ipsec_profile',)),
         (_('Tenancy'), ('tenant_group', 'tenant')),
         (_('Tenancy'), ('tenant_group', 'tenant')),
     )
     )
@@ -44,8 +64,8 @@ class TunnelForm(TenancyForm, NetBoxModelForm):
     class Meta:
     class Meta:
         model = Tunnel
         model = Tunnel
         fields = [
         fields = [
-            'name', 'status', 'encapsulation', 'description', 'tunnel_id', 'ipsec_profile', 'tenant_group', 'tenant',
-            'comments', 'tags',
+            'name', 'status', 'group', 'encapsulation', 'description', 'tunnel_id', 'ipsec_profile', 'tenant_group',
+            'tenant', 'comments', 'tags',
         ]
         ]
 
 
 
 

+ 6 - 0
netbox/vpn/graphql/schema.py

@@ -56,6 +56,12 @@ class VPNQuery(graphene.ObjectType):
     def resolve_tunnel_list(root, info, **kwargs):
     def resolve_tunnel_list(root, info, **kwargs):
         return gql_query_optimizer(models.Tunnel.objects.all(), info)
         return gql_query_optimizer(models.Tunnel.objects.all(), info)
 
 
+    tunnel_group = ObjectField(TunnelGroupType)
+    tunnel_group_list = ObjectListField(TunnelGroupType)
+
+    def resolve_tunnel_group_list(root, info, **kwargs):
+        return gql_query_optimizer(models.TunnelGroup.objects.all(), info)
+
     tunnel_termination = ObjectField(TunnelTerminationType)
     tunnel_termination = ObjectField(TunnelTerminationType)
     tunnel_termination_list = ObjectListField(TunnelTerminationType)
     tunnel_termination_list = ObjectListField(TunnelTerminationType)
 
 

+ 9 - 0
netbox/vpn/graphql/types.py

@@ -12,11 +12,20 @@ __all__ = (
     'IPSecProposalType',
     'IPSecProposalType',
     'L2VPNType',
     'L2VPNType',
     'L2VPNTerminationType',
     'L2VPNTerminationType',
+    'TunnelGroupType',
     'TunnelTerminationType',
     'TunnelTerminationType',
     'TunnelType',
     'TunnelType',
 )
 )
 
 
 
 
+class TunnelGroupType(OrganizationalObjectType):
+
+    class Meta:
+        model = models.TunnelGroup
+        fields = '__all__'
+        filterset_class = filtersets.TunnelGroupFilterSet
+
+
 class TunnelTerminationType(CustomFieldsMixin, TagsMixin, ObjectType):
 class TunnelTerminationType(CustomFieldsMixin, TagsMixin, ObjectType):
 
 
     class Meta:
     class Meta:

+ 101 - 65
netbox/vpn/migrations/0001_initial.py

@@ -16,6 +16,30 @@ class Migration(migrations.Migration):
     ]
     ]
 
 
     operations = [
     operations = [
+        # IKE
+        migrations.CreateModel(
+            name='IKEProposal',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('created', models.DateTimeField(auto_now_add=True, null=True)),
+                ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+                ('description', models.CharField(blank=True, max_length=200)),
+                ('comments', models.TextField(blank=True)),
+                ('name', models.CharField(max_length=100, unique=True)),
+                ('authentication_method', models.CharField()),
+                ('encryption_algorithm', models.CharField()),
+                ('authentication_algorithm', models.CharField()),
+                ('group', models.PositiveSmallIntegerField()),
+                ('sa_lifetime', models.PositiveIntegerField(blank=True, null=True)),
+                ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+            ],
+            options={
+                'verbose_name': 'IKE proposal',
+                'verbose_name_plural': 'IKE proposals',
+                'ordering': ('name',),
+            },
+        ),
         migrations.CreateModel(
         migrations.CreateModel(
             name='IKEPolicy',
             name='IKEPolicy',
             fields=[
             fields=[
@@ -36,6 +60,40 @@ class Migration(migrations.Migration):
                 'ordering': ('name',),
                 'ordering': ('name',),
             },
             },
         ),
         ),
+        migrations.AddField(
+            model_name='ikepolicy',
+            name='proposals',
+            field=models.ManyToManyField(related_name='ike_policies', to='vpn.ikeproposal'),
+        ),
+        migrations.AddField(
+            model_name='ikepolicy',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+
+        # IPSec
+        migrations.CreateModel(
+            name='IPSecProposal',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('created', models.DateTimeField(auto_now_add=True, null=True)),
+                ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+                ('description', models.CharField(blank=True, max_length=200)),
+                ('comments', models.TextField(blank=True)),
+                ('name', models.CharField(max_length=100, unique=True)),
+                ('encryption_algorithm', models.CharField()),
+                ('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.CreateModel(
         migrations.CreateModel(
             name='IPSecPolicy',
             name='IPSecPolicy',
             fields=[
             fields=[
@@ -54,6 +112,16 @@ class Migration(migrations.Migration):
                 'ordering': ('name',),
                 '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(
         migrations.CreateModel(
             name='IPSecProfile',
             name='IPSecProfile',
             fields=[
             fields=[
@@ -75,6 +143,30 @@ class Migration(migrations.Migration):
                 'ordering': ('name',),
                 'ordering': ('name',),
             },
             },
         ),
         ),
+
+        # Tunnels
+        migrations.CreateModel(
+            name='TunnelGroup',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('created', models.DateTimeField(auto_now_add=True, null=True)),
+                ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+                ('name', models.CharField(max_length=100, unique=True)),
+                ('slug', models.SlugField(max_length=100, unique=True)),
+                ('description', models.CharField(blank=True, max_length=200)),
+            ],
+            options={
+                'verbose_name': 'tunnel group',
+                'verbose_name_plural': 'tunnel groups',
+                'ordering': ('name',),
+            },
+        ),
+        migrations.AddField(
+            model_name='tunnelgroup',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
         migrations.CreateModel(
         migrations.CreateModel(
             name='Tunnel',
             name='Tunnel',
             fields=[
             fields=[
@@ -86,6 +178,7 @@ class Migration(migrations.Migration):
                 ('comments', models.TextField(blank=True)),
                 ('comments', models.TextField(blank=True)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('name', models.CharField(max_length=100, unique=True)),
                 ('status', models.CharField(default='active', max_length=50)),
                 ('status', models.CharField(default='active', max_length=50)),
+                ('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tunnels', to='vpn.tunnelgroup')),
                 ('encapsulation', models.CharField(max_length=50)),
                 ('encapsulation', models.CharField(max_length=50)),
                 ('tunnel_id', models.PositiveBigIntegerField(blank=True, null=True)),
                 ('tunnel_id', models.PositiveBigIntegerField(blank=True, null=True)),
                 ('ipsec_profile', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tunnels', to='vpn.ipsecprofile')),
                 ('ipsec_profile', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tunnels', to='vpn.ipsecprofile')),
@@ -98,6 +191,14 @@ class Migration(migrations.Migration):
                 'ordering': ('name',),
                 'ordering': ('name',),
             },
             },
         ),
         ),
+        migrations.AddConstraint(
+            model_name='tunnel',
+            constraint=models.UniqueConstraint(fields=('group', 'name'), name='vpn_tunnel_group_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='tunnel',
+            constraint=models.UniqueConstraint(condition=models.Q(('group__isnull', True)), fields=('name',), name='vpn_tunnel_name'),
+        ),
         migrations.CreateModel(
         migrations.CreateModel(
             name='TunnelTermination',
             name='TunnelTermination',
             fields=[
             fields=[
@@ -118,71 +219,6 @@ class Migration(migrations.Migration):
                 'ordering': ('tunnel', 'role', 'pk'),
                 '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)),
-                ('description', models.CharField(blank=True, max_length=200)),
-                ('comments', models.TextField(blank=True)),
-                ('name', models.CharField(max_length=100, unique=True)),
-                ('encryption_algorithm', models.CharField()),
-                ('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)),
-                ('description', models.CharField(blank=True, max_length=200)),
-                ('comments', models.TextField(blank=True)),
-                ('name', models.CharField(max_length=100, unique=True)),
-                ('authentication_method', models.CharField()),
-                ('encryption_algorithm', models.CharField()),
-                ('authentication_algorithm', models.CharField()),
-                ('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(
         migrations.AddConstraint(
             model_name='tunneltermination',
             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.'),
             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.'),

+ 35 - 1
netbox/vpn/models/tunnels.py

@@ -1,19 +1,35 @@
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.db import models
 from django.db import models
+from django.db.models import Q
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
-from netbox.models import ChangeLoggedModel, PrimaryModel
+from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel
 from netbox.models.features import CustomFieldsMixin, CustomLinksMixin, TagsMixin
 from netbox.models.features import CustomFieldsMixin, CustomLinksMixin, TagsMixin
 from vpn.choices import *
 from vpn.choices import *
 
 
 __all__ = (
 __all__ = (
     'Tunnel',
     'Tunnel',
+    'TunnelGroup',
     'TunnelTermination',
     'TunnelTermination',
 )
 )
 
 
 
 
+class TunnelGroup(OrganizationalModel):
+    """
+    An administrative grouping of Tunnels. This can be used to correlate peer-to-peer tunnels which form a mesh,
+    for example.
+    """
+    class Meta:
+        ordering = ('name',)
+        verbose_name = _('tunnel group')
+        verbose_name_plural = _('tunnel groups')
+
+    def get_absolute_url(self):
+        return reverse('vpn:tunnelgroup', args=[self.pk])
+
+
 class Tunnel(PrimaryModel):
 class Tunnel(PrimaryModel):
     name = models.CharField(
     name = models.CharField(
         verbose_name=_('name'),
         verbose_name=_('name'),
@@ -26,6 +42,13 @@ class Tunnel(PrimaryModel):
         choices=TunnelStatusChoices,
         choices=TunnelStatusChoices,
         default=TunnelStatusChoices.STATUS_ACTIVE
         default=TunnelStatusChoices.STATUS_ACTIVE
     )
     )
+    group = models.ForeignKey(
+        to='vpn.TunnelGroup',
+        on_delete=models.PROTECT,
+        related_name='tunnels',
+        blank=True,
+        null=True
+    )
     encapsulation = models.CharField(
     encapsulation = models.CharField(
         verbose_name=_('encapsulation'),
         verbose_name=_('encapsulation'),
         max_length=50,
         max_length=50,
@@ -57,6 +80,17 @@ class Tunnel(PrimaryModel):
 
 
     class Meta:
     class Meta:
         ordering = ('name',)
         ordering = ('name',)
+        constraints = (
+            models.UniqueConstraint(
+                fields=('group', 'name'),
+                name='%(app_label)s_%(class)s_group_name'
+            ),
+            models.UniqueConstraint(
+                fields=('name',),
+                name='%(app_label)s_%(class)s_name',
+                condition=Q(group__isnull=True)
+            ),
+        )
         verbose_name = _('tunnel')
         verbose_name = _('tunnel')
         verbose_name_plural = _('tunnels')
         verbose_name_plural = _('tunnels')
 
 

+ 23 - 0
netbox/vpn/tables/tunnels.py

@@ -8,10 +8,33 @@ from vpn.models import *
 
 
 __all__ = (
 __all__ = (
     'TunnelTable',
     'TunnelTable',
+    'TunnelGroupTable',
     'TunnelTerminationTable',
     'TunnelTerminationTable',
 )
 )
 
 
 
 
+class TunnelGroupTable(NetBoxTable):
+    name = tables.Column(
+        verbose_name=_('Name'),
+        linkify=True
+    )
+    tunnel_count = columns.LinkedCountColumn(
+        viewname='vpn:tunnel_list',
+        url_params={'group_id': 'pk'},
+        verbose_name=_('Tunnels')
+    )
+    tags = columns.TagColumn(
+        url_name='vpn:tunnelgroup_list'
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = TunnelGroup
+        fields = (
+            'pk', 'id', 'name', 'tunnel_count', 'description', 'slug', 'tags', 'actions', 'created', 'last_updated',
+        )
+        default_columns = ('pk', 'name', 'tunnel_count', 'description')
+
+
 class TunnelTable(TenancyColumnsMixin, NetBoxTable):
 class TunnelTable(TenancyColumnsMixin, NetBoxTable):
     name = tables.Column(
     name = tables.Column(
         verbose_name=_('Name'),
         verbose_name=_('Name'),

+ 44 - 0
netbox/vpn/tests/test_api.py

@@ -17,6 +17,38 @@ class AppTest(APITestCase):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
 
 
+class TunnelGroupTest(APIViewTestCases.APIViewTestCase):
+    model = TunnelGroup
+    brief_fields = ['display', 'id', 'name', 'slug', 'tunnel_count', 'url']
+    create_data = (
+        {
+            'name': 'Tunnel Group 4',
+            'slug': 'tunnel-group-4',
+        },
+        {
+            'name': 'Tunnel Group 5',
+            'slug': 'tunnel-group-5',
+        },
+        {
+            'name': 'Tunnel Group 6',
+            'slug': 'tunnel-group-6',
+        },
+    )
+    bulk_update_data = {
+        'description': 'New description',
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+
+        tunnel_groups = (
+            TunnelGroup(name='Tunnel Group 1', slug='tunnel-group-1'),
+            TunnelGroup(name='Tunnel Group 2', slug='tunnel-group-2'),
+            TunnelGroup(name='Tunnel Group 3', slug='tunnel-group-3'),
+        )
+        TunnelGroup.objects.bulk_create(tunnel_groups)
+
+
 class TunnelTest(APIViewTestCases.APIViewTestCase):
 class TunnelTest(APIViewTestCases.APIViewTestCase):
     model = Tunnel
     model = Tunnel
     brief_fields = ['display', 'id', 'name', 'url']
     brief_fields = ['display', 'id', 'name', 'url']
@@ -29,20 +61,29 @@ class TunnelTest(APIViewTestCases.APIViewTestCase):
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
 
 
+        tunnel_groups = (
+            TunnelGroup(name='Tunnel Group 1', slug='tunnel-group-1'),
+            TunnelGroup(name='Tunnel Group 2', slug='tunnel-group-2'),
+        )
+        TunnelGroup.objects.bulk_create(tunnel_groups)
+
         tunnels = (
         tunnels = (
             Tunnel(
             Tunnel(
                 name='Tunnel 1',
                 name='Tunnel 1',
                 status=TunnelStatusChoices.STATUS_ACTIVE,
                 status=TunnelStatusChoices.STATUS_ACTIVE,
+                group=tunnel_groups[0],
                 encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP
                 encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP
             ),
             ),
             Tunnel(
             Tunnel(
                 name='Tunnel 2',
                 name='Tunnel 2',
                 status=TunnelStatusChoices.STATUS_ACTIVE,
                 status=TunnelStatusChoices.STATUS_ACTIVE,
+                group=tunnel_groups[0],
                 encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP
                 encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP
             ),
             ),
             Tunnel(
             Tunnel(
                 name='Tunnel 3',
                 name='Tunnel 3',
                 status=TunnelStatusChoices.STATUS_ACTIVE,
                 status=TunnelStatusChoices.STATUS_ACTIVE,
+                group=tunnel_groups[0],
                 encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP
                 encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP
             ),
             ),
         )
         )
@@ -52,16 +93,19 @@ class TunnelTest(APIViewTestCases.APIViewTestCase):
             {
             {
                 'name': 'Tunnel 4',
                 'name': 'Tunnel 4',
                 'status': TunnelStatusChoices.STATUS_DISABLED,
                 'status': TunnelStatusChoices.STATUS_DISABLED,
+                'group': tunnel_groups[1].pk,
                 'encapsulation': TunnelEncapsulationChoices.ENCAP_GRE,
                 'encapsulation': TunnelEncapsulationChoices.ENCAP_GRE,
             },
             },
             {
             {
                 'name': 'Tunnel 5',
                 'name': 'Tunnel 5',
                 'status': TunnelStatusChoices.STATUS_DISABLED,
                 'status': TunnelStatusChoices.STATUS_DISABLED,
+                'group': tunnel_groups[1].pk,
                 'encapsulation': TunnelEncapsulationChoices.ENCAP_GRE,
                 'encapsulation': TunnelEncapsulationChoices.ENCAP_GRE,
             },
             },
             {
             {
                 'name': 'Tunnel 6',
                 'name': 'Tunnel 6',
                 'status': TunnelStatusChoices.STATUS_DISABLED,
                 'status': TunnelStatusChoices.STATUS_DISABLED,
+                'group': tunnel_groups[1].pk,
                 'encapsulation': TunnelEncapsulationChoices.ENCAP_GRE,
                 'encapsulation': TunnelEncapsulationChoices.ENCAP_GRE,
             },
             },
         ]
         ]

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

@@ -11,6 +11,32 @@ from vpn.filtersets import *
 from vpn.models import *
 from vpn.models import *
 
 
 
 
+class TunnelGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = TunnelGroup.objects.all()
+    filterset = TunnelGroupFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+
+        TunnelGroup.objects.bulk_create((
+            TunnelGroup(name='Tunnel Group 1', slug='tunnel-group-1', description='foobar1'),
+            TunnelGroup(name='Tunnel Group 2', slug='tunnel-group-2', description='foobar2'),
+            TunnelGroup(name='Tunnel Group 3', slug='tunnel-group-3'),
+        ))
+
+    def test_name(self):
+        params = {'name': ['Tunnel Group 1']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_slug(self):
+        params = {'slug': ['tunnel-group-1']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_description(self):
+        params = {'description': ['foobar1', 'foobar2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
 class TunnelTestCase(TestCase, ChangeLoggedFilterSetTests):
 class TunnelTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Tunnel.objects.all()
     queryset = Tunnel.objects.all()
     filterset = TunnelFilterSet
     filterset = TunnelFilterSet
@@ -56,10 +82,18 @@ class TunnelTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         )
         IPSecProfile.objects.bulk_create(ipsec_profiles)
         IPSecProfile.objects.bulk_create(ipsec_profiles)
 
 
+        tunnel_groups = (
+            TunnelGroup(name='Tunnel Group 1', slug='tunnel-group-1'),
+            TunnelGroup(name='Tunnel Group 2', slug='tunnel-group-2'),
+            TunnelGroup(name='Tunnel Group 3', slug='tunnel-group-3'),
+        )
+        TunnelGroup.objects.bulk_create(tunnel_groups)
+
         tunnels = (
         tunnels = (
             Tunnel(
             Tunnel(
                 name='Tunnel 1',
                 name='Tunnel 1',
                 status=TunnelStatusChoices.STATUS_ACTIVE,
                 status=TunnelStatusChoices.STATUS_ACTIVE,
+                group=tunnel_groups[0],
                 encapsulation=TunnelEncapsulationChoices.ENCAP_GRE,
                 encapsulation=TunnelEncapsulationChoices.ENCAP_GRE,
                 ipsec_profile=ipsec_profiles[0],
                 ipsec_profile=ipsec_profiles[0],
                 tunnel_id=100
                 tunnel_id=100
@@ -67,6 +101,7 @@ class TunnelTestCase(TestCase, ChangeLoggedFilterSetTests):
             Tunnel(
             Tunnel(
                 name='Tunnel 2',
                 name='Tunnel 2',
                 status=TunnelStatusChoices.STATUS_PLANNED,
                 status=TunnelStatusChoices.STATUS_PLANNED,
+                group=tunnel_groups[1],
                 encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP,
                 encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP,
                 ipsec_profile=ipsec_profiles[0],
                 ipsec_profile=ipsec_profiles[0],
                 tunnel_id=200
                 tunnel_id=200
@@ -74,6 +109,7 @@ class TunnelTestCase(TestCase, ChangeLoggedFilterSetTests):
             Tunnel(
             Tunnel(
                 name='Tunnel 3',
                 name='Tunnel 3',
                 status=TunnelStatusChoices.STATUS_DISABLED,
                 status=TunnelStatusChoices.STATUS_DISABLED,
+                group=tunnel_groups[2],
                 encapsulation=TunnelEncapsulationChoices.ENCAP_IPSEC_TUNNEL,
                 encapsulation=TunnelEncapsulationChoices.ENCAP_IPSEC_TUNNEL,
                 ipsec_profile=None,
                 ipsec_profile=None,
                 tunnel_id=300
                 tunnel_id=300
@@ -89,6 +125,13 @@ class TunnelTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'status': [TunnelStatusChoices.STATUS_ACTIVE, TunnelStatusChoices.STATUS_PLANNED]}
         params = {'status': [TunnelStatusChoices.STATUS_ACTIVE, TunnelStatusChoices.STATUS_PLANNED]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_group(self):
+        tunnel_groups = TunnelGroup.objects.all()[:2]
+        params = {'group_id': [tunnel_groups[0].pk, tunnel_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'group': [tunnel_groups[0].slug, tunnel_groups[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_encapsulation(self):
     def test_encapsulation(self):
         params = {'encapsulation': [TunnelEncapsulationChoices.ENCAP_GRE, TunnelEncapsulationChoices.ENCAP_IP_IP]}
         params = {'encapsulation': [TunnelEncapsulationChoices.ENCAP_GRE, TunnelEncapsulationChoices.ENCAP_IP_IP]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

+ 62 - 8
netbox/vpn/tests/test_views.py

@@ -6,26 +6,78 @@ from vpn.choices import *
 from vpn.models import *
 from vpn.models import *
 
 
 
 
+class TunnelGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
+    model = TunnelGroup
+
+    @classmethod
+    def setUpTestData(cls):
+
+        tunnel_groups = (
+            TunnelGroup(name='Tunnel Group 1', slug='tunnel-group-1'),
+            TunnelGroup(name='Tunnel Group 2', slug='tunnel-group-2'),
+            TunnelGroup(name='Tunnel Group 3', slug='tunnel-group-3'),
+        )
+        TunnelGroup.objects.bulk_create(tunnel_groups)
+
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+        cls.form_data = {
+            'name': 'Tunnel Group X',
+            'slug': 'tunnel-group-x',
+            'description': 'A new Tunnel Group',
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.csv_data = (
+            "name,slug",
+            "Tunnel Group 4,tunnel-group-4",
+            "Tunnel Group 5,tunnel-group-5",
+            "Tunnel Group 6,tunnel-group-6",
+        )
+
+        cls.csv_update_data = (
+            "id,name,description",
+            f"{tunnel_groups[0].pk},Tunnel Group 7,New description7",
+            f"{tunnel_groups[1].pk},Tunnel Group 8,New description8",
+            f"{tunnel_groups[2].pk},Tunnel Group 9,New description9",
+        )
+
+        cls.bulk_edit_data = {
+            'description': 'Foo',
+        }
+
+
 class TunnelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 class TunnelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = Tunnel
     model = Tunnel
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
 
 
+        tunnel_groups = (
+            TunnelGroup(name='Tunnel Group 1', slug='tunnel-group-1'),
+            TunnelGroup(name='Tunnel Group 2', slug='tunnel-group-2'),
+            TunnelGroup(name='Tunnel Group 3', slug='tunnel-group-3'),
+            TunnelGroup(name='Tunnel Group 4', slug='tunnel-group-4'),
+        )
+        TunnelGroup.objects.bulk_create(tunnel_groups)
+
         tunnels = (
         tunnels = (
             Tunnel(
             Tunnel(
                 name='Tunnel 1',
                 name='Tunnel 1',
                 status=TunnelStatusChoices.STATUS_ACTIVE,
                 status=TunnelStatusChoices.STATUS_ACTIVE,
+                group=tunnel_groups[0],
                 encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP
                 encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP
             ),
             ),
             Tunnel(
             Tunnel(
                 name='Tunnel 2',
                 name='Tunnel 2',
                 status=TunnelStatusChoices.STATUS_ACTIVE,
                 status=TunnelStatusChoices.STATUS_ACTIVE,
+                group=tunnel_groups[1],
                 encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP
                 encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP
             ),
             ),
             Tunnel(
             Tunnel(
                 name='Tunnel 3',
                 name='Tunnel 3',
                 status=TunnelStatusChoices.STATUS_ACTIVE,
                 status=TunnelStatusChoices.STATUS_ACTIVE,
+                group=tunnel_groups[2],
                 encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP
                 encapsulation=TunnelEncapsulationChoices.ENCAP_IP_IP
             ),
             ),
         )
         )
@@ -37,26 +89,28 @@ class TunnelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'name': 'Tunnel X',
             'name': 'Tunnel X',
             'description': 'New tunnel',
             'description': 'New tunnel',
             'status': TunnelStatusChoices.STATUS_PLANNED,
             'status': TunnelStatusChoices.STATUS_PLANNED,
+            'group': tunnel_groups[3].pk,
             'encapsulation': TunnelEncapsulationChoices.ENCAP_GRE,
             'encapsulation': TunnelEncapsulationChoices.ENCAP_GRE,
             'tags': [t.pk for t in tags],
             'tags': [t.pk for t in tags],
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
-            "name,status,encapsulation",
-            "Tunnel 4,planned,gre",
-            "Tunnel 5,planned,gre",
-            "Tunnel 6,planned,gre",
+            "name,status,group,encapsulation",
+            "Tunnel 4,planned,Tunnel Group 1,gre",
+            "Tunnel 5,planned,Tunnel Group 2,gre",
+            "Tunnel 6,planned,Tunnel Group 3,gre",
         )
         )
 
 
         cls.csv_update_data = (
         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",
+            "id,status,group,encapsulation",
+            f"{tunnels[0].pk},active,Tunnel Group 4,ip-ip",
+            f"{tunnels[1].pk},active,Tunnel Group 4,ip-ip",
+            f"{tunnels[2].pk},active,Tunnel Group 4,ip-ip",
         )
         )
 
 
         cls.bulk_edit_data = {
         cls.bulk_edit_data = {
             'description': 'New description',
             'description': 'New description',
+            'group': tunnel_groups[3].pk,
             'status': TunnelStatusChoices.STATUS_DISABLED,
             'status': TunnelStatusChoices.STATUS_DISABLED,
             'encapsulation': TunnelEncapsulationChoices.ENCAP_GRE,
             'encapsulation': TunnelEncapsulationChoices.ENCAP_GRE,
         }
         }

+ 8 - 0
netbox/vpn/urls.py

@@ -6,6 +6,14 @@ from . import views
 app_name = 'vpn'
 app_name = 'vpn'
 urlpatterns = [
 urlpatterns = [
 
 
+    # Tunnel groups
+    path('tunnel-groups/', views.TunnelGroupListView.as_view(), name='tunnelgroup_list'),
+    path('tunnel-groups/add/', views.TunnelGroupEditView.as_view(), name='tunnelgroup_add'),
+    path('tunnel-groups/import/', views.TunnelGroupBulkImportView.as_view(), name='tunnelgroup_import'),
+    path('tunnel-groups/edit/', views.TunnelGroupBulkEditView.as_view(), name='tunnelgroup_bulk_edit'),
+    path('tunnel-groups/delete/', views.TunnelGroupBulkDeleteView.as_view(), name='tunnelgroup_bulk_delete'),
+    path('tunnel-groups/<int:pk>/', include(get_model_urls('vpn', 'tunnelgroup'))),
+
     # Tunnels
     # Tunnels
     path('tunnels/', views.TunnelListView.as_view(), name='tunnel_list'),
     path('tunnels/', views.TunnelListView.as_view(), name='tunnel_list'),
     path('tunnels/add/', views.TunnelEditView.as_view(), name='tunnel_add'),
     path('tunnels/add/', views.TunnelEditView.as_view(), name='tunnel_add'),

+ 60 - 0
netbox/vpn/views.py

@@ -7,6 +7,66 @@ from . import filtersets, forms, tables
 from .models import *
 from .models import *
 
 
 
 
+#
+# Tunnel groups
+#
+
+class TunnelGroupListView(generic.ObjectListView):
+    queryset = TunnelGroup.objects.annotate(
+        tunnel_count=count_related(Tunnel, 'group')
+    )
+    filterset = filtersets.TunnelGroupFilterSet
+    filterset_form = forms.TunnelGroupFilterForm
+    table = tables.TunnelGroupTable
+
+
+@register_model_view(TunnelGroup)
+class TunnelGroupView(generic.ObjectView):
+    queryset = TunnelGroup.objects.all()
+
+    def get_extra_context(self, request, instance):
+        related_models = (
+            (Tunnel.objects.restrict(request.user, 'view').filter(group=instance), 'group_id'),
+        )
+
+        return {
+            'related_models': related_models,
+        }
+
+
+@register_model_view(TunnelGroup, 'edit')
+class TunnelGroupEditView(generic.ObjectEditView):
+    queryset = TunnelGroup.objects.all()
+    form = forms.TunnelGroupForm
+
+
+@register_model_view(TunnelGroup, 'delete')
+class TunnelGroupDeleteView(generic.ObjectDeleteView):
+    queryset = TunnelGroup.objects.all()
+
+
+class TunnelGroupBulkImportView(generic.BulkImportView):
+    queryset = TunnelGroup.objects.all()
+    model_form = forms.TunnelGroupImportForm
+
+
+class TunnelGroupBulkEditView(generic.BulkEditView):
+    queryset = TunnelGroup.objects.annotate(
+        tunnel_count=count_related(Tunnel, 'group')
+    )
+    filterset = filtersets.TunnelGroupFilterSet
+    table = tables.TunnelGroupTable
+    form = forms.TunnelGroupBulkEditForm
+
+
+class TunnelGroupBulkDeleteView(generic.BulkDeleteView):
+    queryset = TunnelGroup.objects.annotate(
+        tunnel_count=count_related(Tunnel, 'group')
+    )
+    filterset = filtersets.TunnelGroupFilterSet
+    table = tables.TunnelGroupTable
+
+
 #
 #
 # Tunnels
 # Tunnels
 #
 #