Jeremy Stretch 2 gadi atpakaļ
vecāks
revīzija
8db1093fdc

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

@@ -1,6 +1,6 @@
 # Tunnels
 
-NetBox can model private tunnels formed among virtual termination points across your network. Typical tunnel implementations include GRE, IP-in-IP, 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
 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:
 
-| Name           |
-|----------------|
-| Planned        |
-| Active         |
-| Disabled       |
+* Planned
+* Active
+* Disabled
 
 !!! tip "Custom tunnel statuses"
     Additional tunnel statuses may be defined by setting `Tunnel.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
 
+### Group
+
+The [administrative group](./tunnelgroup.md) to which this tunnel is assigned (optional).
+
 ### Encapsulation
 
 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 %}
 {% elif record.type == 'virtual' %}
     {% 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>
         </a>
     {% 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'),
             items=(
                 get_model_item('vpn', 'tunnel', _('Tunnels')),
+                get_model_item('vpn', 'tunnelgroup', _('Tunnel Groups')),
                 get_model_item('vpn', 'tunneltermination', _('Tunnel Terminations')),
             ),
         ),

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

@@ -26,6 +26,10 @@
               <th scope="row">{% trans "Status" %}</th>
               <td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
             </tr>
+            <tr>
+              <th scope="row">{% trans "Group" %}</th>
+              <td>{{ object.group|linkify|placeholder }}</td>
+            </tr>
             <tr>
               <th scope="row">{% trans "Description" %}</th>
               <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 netbox.api.serializers import WritableNestedSerializer
@@ -11,11 +12,24 @@ __all__ = (
     'NestedIPSecProposalSerializer',
     'NestedL2VPNSerializer',
     'NestedL2VPNTerminationSerializer',
+    'NestedTunnelGroupSerializer',
     'NestedTunnelSerializer',
     'NestedTunnelTerminationSerializer',
 )
 
 
+@extend_schema_serializer(
+    exclude_fields=('tunnel_count',),
+)
+class NestedTunnelGroupSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='vpn-api:tunnelgroup-detail')
+    tunnel_count = serializers.IntegerField(read_only=True)
+
+    class Meta:
+        model = models.TunnelGroup
+        fields = ['id', 'url', 'display', 'name', 'slug', 'tunnel_count']
+
+
 class NestedTunnelSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(
         view_name='vpn-api:tunnel-detail'

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

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

+ 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-proposals', views.IPSecProposalViewSet)
 router.register('ipsec-profiles', views.IPSecProfileViewSet)
+router.register('tunnel-groups', views.TunnelGroupViewSet)
 router.register('tunnels', views.TunnelViewSet)
 router.register('tunnel-terminations', views.TunnelTerminationViewSet)
 router.register('l2vpns', views.L2VPNViewSet)

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

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

+ 19 - 1
netbox/vpn/filtersets.py

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

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

@@ -17,16 +17,33 @@ __all__ = (
     'L2VPNBulkEditForm',
     'L2VPNTerminationBulkEditForm',
     'TunnelBulkEditForm',
+    'TunnelGroupBulkEditForm',
     'TunnelTerminationBulkEditForm',
 )
 
 
+class TunnelGroupBulkEditForm(NetBoxModelBulkEditForm):
+    description = forms.CharField(
+        label=_('Description'),
+        max_length=200,
+        required=False
+    )
+
+    model = TunnelGroup
+    nullable_fields = ('description',)
+
+
 class TunnelBulkEditForm(NetBoxModelBulkEditForm):
     status = forms.ChoiceField(
         label=_('Status'),
         choices=add_blank_choice(TunnelStatusChoices),
         required=False
     )
+    group = DynamicModelChoiceField(
+        queryset=TunnelGroup.objects.all(),
+        label=_('Tunnel group'),
+        required=False
+    )
     encapsulation = forms.ChoiceField(
         label=_('Encapsulation'),
         choices=add_blank_choice(TunnelEncapsulationChoices),
@@ -55,12 +72,12 @@ class TunnelBulkEditForm(NetBoxModelBulkEditForm):
 
     model = Tunnel
     fieldsets = (
-        (_('Tunnel'), ('status', 'encapsulation', 'tunnel_id', 'description')),
+        (_('Tunnel'), ('status', 'group', 'encapsulation', 'tunnel_id', 'description')),
         (_('Security'), ('ipsec_profile',)),
         (_('Tenancy'), ('tenant',)),
     )
     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 netbox.forms import NetBoxModelImportForm
 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 vpn.choices import *
 from vpn.models import *
@@ -19,16 +19,31 @@ __all__ = (
     'L2VPNImportForm',
     'L2VPNTerminationImportForm',
     'TunnelImportForm',
+    'TunnelGroupImportForm',
     'TunnelTerminationImportForm',
 )
 
 
+class TunnelGroupImportForm(NetBoxModelImportForm):
+    slug = SlugField()
+
+    class Meta:
+        model = TunnelGroup
+        fields = ('name', 'slug', 'description', 'tags')
+
+
 class TunnelImportForm(NetBoxModelImportForm):
     status = CSVChoiceField(
         label=_('Status'),
         choices=TunnelStatusChoices,
         help_text=_('Operational status')
     )
+    group = CSVModelChoiceField(
+        label=_('Tunnel group'),
+        queryset=TunnelGroup.objects.all(),
+        required=False,
+        to_field_name='name'
+    )
     encapsulation = CSVChoiceField(
         label=_('Encapsulation'),
         choices=TunnelEncapsulationChoices,
@@ -51,8 +66,8 @@ class TunnelImportForm(NetBoxModelImportForm):
     class Meta:
         model = Tunnel
         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',
     'L2VPNTerminationFilterForm',
     'TunnelFilterForm',
+    'TunnelGroupFilterForm',
     'TunnelTerminationFilterForm',
 )
 
 
+class TunnelGroupFilterForm(NetBoxModelFilterSetForm):
+    model = TunnelGroup
+    tag = TagFilterField(model)
+
+
 class TunnelFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     model = Tunnel
     fieldsets = (
@@ -41,6 +47,11 @@ class TunnelFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
         choices=TunnelStatusChoices,
         required=False
     )
+    group_id = DynamicModelMultipleChoiceField(
+        queryset=TunnelGroup.objects.all(),
+        required=False,
+        label=_('Tunnel group')
+    )
     encapsulation = forms.MultipleChoiceField(
         label=_('Encapsulation'),
         choices=TunnelEncapsulationChoices,

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

@@ -23,11 +23,31 @@ __all__ = (
     'L2VPNTerminationForm',
     'TunnelCreateForm',
     'TunnelForm',
+    'TunnelGroupForm',
     'TunnelTerminationForm',
 )
 
 
+class TunnelGroupForm(NetBoxModelForm):
+    slug = SlugField()
+
+    fieldsets = (
+        (_('Tunnel Group'), ('name', 'slug', 'description', 'tags')),
+    )
+
+    class Meta:
+        model = TunnelGroup
+        fields = [
+            'name', 'slug', 'description', 'tags',
+        ]
+
+
 class TunnelForm(TenancyForm, NetBoxModelForm):
+    group = DynamicModelChoiceField(
+        queryset=TunnelGroup.objects.all(),
+        label=_('Tunnel Group'),
+        required=False
+    )
     ipsec_profile = DynamicModelChoiceField(
         queryset=IPSecProfile.objects.all(),
         label=_('IPSec Profile'),
@@ -36,7 +56,7 @@ class TunnelForm(TenancyForm, NetBoxModelForm):
     comments = CommentField()
 
     fieldsets = (
-        (_('Tunnel'), ('name', 'status', 'encapsulation', 'description', 'tunnel_id', 'tags')),
+        (_('Tunnel'), ('name', 'status', 'group', 'encapsulation', 'description', 'tunnel_id', 'tags')),
         (_('Security'), ('ipsec_profile',)),
         (_('Tenancy'), ('tenant_group', 'tenant')),
     )
@@ -44,8 +64,8 @@ class TunnelForm(TenancyForm, NetBoxModelForm):
     class Meta:
         model = Tunnel
         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):
         return gql_query_optimizer(models.Tunnel.objects.all(), info)
 
+    tunnel_group = ObjectField(TunnelGroupType)
+    tunnel_group_list = ObjectListField(TunnelGroupType)
+
+    def resolve_tunnel_group_list(root, info, **kwargs):
+        return gql_query_optimizer(models.TunnelGroup.objects.all(), info)
+
     tunnel_termination = ObjectField(TunnelTerminationType)
     tunnel_termination_list = ObjectListField(TunnelTerminationType)
 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 8 - 0
netbox/vpn/urls.py

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

+ 60 - 0
netbox/vpn/views.py

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