diff --git a/docs/features/vpn-tunnels.md b/docs/features/vpn-tunnels.md
index ae6df70c8..4ebb91ab7 100644
--- a/docs/features/vpn-tunnels.md
+++ b/docs/features/vpn-tunnels.md
@@ -1,6 +1,6 @@
# Tunnels
-NetBox can model private tunnels formed among virtual termination points across your network. Typical tunnel implementations include GRE, IP-in-IP, 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
diff --git a/docs/models/vpn/tunnel.md b/docs/models/vpn/tunnel.md
index ebe004da1..31625f7d6 100644
--- a/docs/models/vpn/tunnel.md
+++ b/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.
diff --git a/docs/models/vpn/tunnelgroup.md b/docs/models/vpn/tunnelgroup.md
new file mode 100644
index 000000000..7e3a5c3cc
--- /dev/null
+++ b/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.)
diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py
index bf2ce9de4..1862893ff 100644
--- a/netbox/dcim/tables/template_code.py
+++ b/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 %}
-
+
{% elif perms.vpn.delete_tunneltermination and record.tunnel_termination %}
diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py
index e01e65cc8..d4969386e 100644
--- a/netbox/netbox/navigation/menu.py
+++ b/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')),
),
),
diff --git a/netbox/templates/vpn/tunnel.html b/netbox/templates/vpn/tunnel.html
index 544ffadae..d1607bd95 100644
--- a/netbox/templates/vpn/tunnel.html
+++ b/netbox/templates/vpn/tunnel.html
@@ -26,6 +26,10 @@
{% trans "Status" %} |
{% badge object.get_status_display bg_color=object.get_status_color %} |
+
+ {% trans "Group" %} |
+ {{ object.group|linkify|placeholder }} |
+
{% trans "Description" %} |
{{ object.description|placeholder }} |
diff --git a/netbox/templates/vpn/tunnelgroup.html b/netbox/templates/vpn/tunnelgroup.html
new file mode 100644
index 000000000..3afea48c4
--- /dev/null
+++ b/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 %}
+ {% trans "Tunnel Groups" %}
+{% endblock %}
+
+{% block extra_controls %}
+ {% if perms.vpn.add_tunnel %}
+
+ {% trans "Add Tunnel" %}
+
+ {% endif %}
+{% endblock extra_controls %}
+
+{% block content %}
+
+
+
+
+
+
+
+ {% trans "Name" %} |
+ {{ object.name }} |
+
+
+ {% trans "Description" %} |
+ {{ object.description|placeholder }} |
+
+
+
+
+ {% include 'inc/panels/tags.html' %}
+ {% plugin_left_page object %}
+
+
+ {% include 'inc/panels/related_objects.html' %}
+ {% include 'inc/panels/custom_fields.html' %}
+ {% plugin_right_page object %}
+
+
+
+
+ {% plugin_full_width_page object %}
+
+
+{% endblock %}
diff --git a/netbox/vpn/api/nested_serializers.py b/netbox/vpn/api/nested_serializers.py
index f2627869b..1042b375e 100644
--- a/netbox/vpn/api/nested_serializers.py
+++ b/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'
diff --git a/netbox/vpn/api/serializers.py b/netbox/vpn/api/serializers.py
index 176deba04..dedcbfbf5 100644
--- a/netbox/vpn/api/serializers.py
+++ b/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',
)
diff --git a/netbox/vpn/api/urls.py b/netbox/vpn/api/urls.py
index 8938532dd..5358325f3 100644
--- a/netbox/vpn/api/urls.py
+++ b/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)
diff --git a/netbox/vpn/api/views.py b/netbox/vpn/api/views.py
index 9a691a171..58ad2f47d 100644
--- a/netbox/vpn/api/views.py
+++ b/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')
diff --git a/netbox/vpn/filtersets.py b/netbox/vpn/filtersets.py
index 2efd0189c..fbdbb2418 100644
--- a/netbox/vpn/filtersets.py
+++ b/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
)
diff --git a/netbox/vpn/forms/bulk_edit.py b/netbox/vpn/forms/bulk_edit.py
index 4cbfd950d..a976c5659 100644
--- a/netbox/vpn/forms/bulk_edit.py
+++ b/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',
)
diff --git a/netbox/vpn/forms/bulk_import.py b/netbox/vpn/forms/bulk_import.py
index 37da63da3..c5d53eb1d 100644
--- a/netbox/vpn/forms/bulk_import.py
+++ b/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',
)
diff --git a/netbox/vpn/forms/filtersets.py b/netbox/vpn/forms/filtersets.py
index 91ca8a8dc..a9326c4bc 100644
--- a/netbox/vpn/forms/filtersets.py
+++ b/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,
diff --git a/netbox/vpn/forms/model_forms.py b/netbox/vpn/forms/model_forms.py
index 5c3db1c99..5b71c24aa 100644
--- a/netbox/vpn/forms/model_forms.py
+++ b/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',
]
diff --git a/netbox/vpn/graphql/schema.py b/netbox/vpn/graphql/schema.py
index 9c8e1e502..6737957d4 100644
--- a/netbox/vpn/graphql/schema.py
+++ b/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)
diff --git a/netbox/vpn/graphql/types.py b/netbox/vpn/graphql/types.py
index 840a44c7b..0bfebb441 100644
--- a/netbox/vpn/graphql/types.py
+++ b/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:
diff --git a/netbox/vpn/migrations/0001_initial.py b/netbox/vpn/migrations/0001_initial.py
index 17e000e53..efa799293 100644
--- a/netbox/vpn/migrations/0001_initial.py
+++ b/netbox/vpn/migrations/0001_initial.py
@@ -16,140 +16,7 @@ class Migration(migrations.Migration):
]
operations = [
- migrations.CreateModel(
- name='IKEPolicy',
- fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
- ('created', models.DateTimeField(auto_now_add=True, null=True)),
- ('last_updated', models.DateTimeField(auto_now=True, null=True)),
- ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
- ('description', models.CharField(blank=True, max_length=200)),
- ('comments', models.TextField(blank=True)),
- ('name', models.CharField(max_length=100, unique=True)),
- ('version', models.PositiveSmallIntegerField(default=2)),
- ('mode', models.CharField()),
- ('preshared_key', models.TextField(blank=True)),
- ],
- options={
- 'verbose_name': 'IKE policy',
- 'verbose_name_plural': 'IKE policies',
- 'ordering': ('name',),
- },
- ),
- migrations.CreateModel(
- name='IPSecPolicy',
- fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
- ('created', models.DateTimeField(auto_now_add=True, null=True)),
- ('last_updated', models.DateTimeField(auto_now=True, null=True)),
- ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
- ('description', models.CharField(blank=True, max_length=200)),
- ('comments', models.TextField(blank=True)),
- ('name', models.CharField(max_length=100, unique=True)),
- ('pfs_group', models.PositiveSmallIntegerField(blank=True, null=True)),
- ],
- options={
- 'verbose_name': 'IPSec policy',
- 'verbose_name_plural': 'IPSec policies',
- 'ordering': ('name',),
- },
- ),
- migrations.CreateModel(
- name='IPSecProfile',
- fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
- ('created', models.DateTimeField(auto_now_add=True, null=True)),
- ('last_updated', models.DateTimeField(auto_now=True, null=True)),
- ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
- ('description', models.CharField(blank=True, max_length=200)),
- ('comments', models.TextField(blank=True)),
- ('name', models.CharField(max_length=100, unique=True)),
- ('mode', models.CharField()),
- ('ike_policy', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='ipsec_profiles', to='vpn.ikepolicy')),
- ('ipsec_policy', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='ipsec_profiles', to='vpn.ipsecpolicy')),
- ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
- ],
- options={
- 'verbose_name': 'IPSec profile',
- 'verbose_name_plural': 'IPSec profiles',
- 'ordering': ('name',),
- },
- ),
- migrations.CreateModel(
- name='Tunnel',
- fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
- ('created', models.DateTimeField(auto_now_add=True, null=True)),
- ('last_updated', models.DateTimeField(auto_now=True, null=True)),
- ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
- ('description', models.CharField(blank=True, max_length=200)),
- ('comments', models.TextField(blank=True)),
- ('name', models.CharField(max_length=100, unique=True)),
- ('status', models.CharField(default='active', max_length=50)),
- ('encapsulation', models.CharField(max_length=50)),
- ('tunnel_id', models.PositiveBigIntegerField(blank=True, null=True)),
- ('ipsec_profile', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tunnels', to='vpn.ipsecprofile')),
- ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
- ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tunnels', to='tenancy.tenant')),
- ],
- options={
- 'verbose_name': 'tunnel',
- 'verbose_name_plural': 'tunnels',
- 'ordering': ('name',),
- },
- ),
- migrations.CreateModel(
- name='TunnelTermination',
- fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
- ('created', models.DateTimeField(auto_now_add=True, null=True)),
- ('last_updated', models.DateTimeField(auto_now=True, null=True)),
- ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
- ('role', models.CharField(default='peer', max_length=50)),
- ('termination_id', models.PositiveBigIntegerField(blank=True, null=True)),
- ('termination_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')),
- ('outside_ip', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tunnel_termination', to='ipam.ipaddress')),
- ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
- ('tunnel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='vpn.tunnel')),
- ],
- options={
- 'verbose_name': 'tunnel termination',
- 'verbose_name_plural': 'tunnel terminations',
- 'ordering': ('tunnel', 'role', 'pk'),
- },
- ),
- migrations.CreateModel(
- name='IPSecProposal',
- fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
- ('created', models.DateTimeField(auto_now_add=True, null=True)),
- ('last_updated', models.DateTimeField(auto_now=True, null=True)),
- ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
- ('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'),
- ),
+ # IKE
migrations.CreateModel(
name='IKEProposal',
fields=[
@@ -173,6 +40,26 @@ class Migration(migrations.Migration):
'ordering': ('name',),
},
),
+ migrations.CreateModel(
+ name='IKEPolicy',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+ ('created', models.DateTimeField(auto_now_add=True, null=True)),
+ ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+ ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+ ('description', models.CharField(blank=True, max_length=200)),
+ ('comments', models.TextField(blank=True)),
+ ('name', models.CharField(max_length=100, unique=True)),
+ ('version', models.PositiveSmallIntegerField(default=2)),
+ ('mode', models.CharField()),
+ ('preshared_key', models.TextField(blank=True)),
+ ],
+ options={
+ 'verbose_name': 'IKE policy',
+ 'verbose_name_plural': 'IKE policies',
+ 'ordering': ('name',),
+ },
+ ),
migrations.AddField(
model_name='ikepolicy',
name='proposals',
@@ -183,6 +70,155 @@ class Migration(migrations.Migration):
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=[
+ ('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)),
+ ('pfs_group', models.PositiveSmallIntegerField(blank=True, null=True)),
+ ],
+ options={
+ 'verbose_name': 'IPSec policy',
+ 'verbose_name_plural': 'IPSec policies',
+ '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=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+ ('created', models.DateTimeField(auto_now_add=True, null=True)),
+ ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+ ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+ ('description', models.CharField(blank=True, max_length=200)),
+ ('comments', models.TextField(blank=True)),
+ ('name', models.CharField(max_length=100, unique=True)),
+ ('mode', models.CharField()),
+ ('ike_policy', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='ipsec_profiles', to='vpn.ikepolicy')),
+ ('ipsec_policy', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='ipsec_profiles', to='vpn.ipsecpolicy')),
+ ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+ ],
+ options={
+ 'verbose_name': 'IPSec profile',
+ 'verbose_name_plural': 'IPSec profiles',
+ 'ordering': ('name',),
+ },
+ ),
+
+ # 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=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+ ('created', models.DateTimeField(auto_now_add=True, null=True)),
+ ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+ ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+ ('description', models.CharField(blank=True, max_length=200)),
+ ('comments', models.TextField(blank=True)),
+ ('name', models.CharField(max_length=100, unique=True)),
+ ('status', models.CharField(default='active', max_length=50)),
+ ('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')),
+ ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+ ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tunnels', to='tenancy.tenant')),
+ ],
+ options={
+ 'verbose_name': 'tunnel',
+ 'verbose_name_plural': 'tunnels',
+ 'ordering': ('name',),
+ },
+ ),
+ migrations.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=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+ ('created', models.DateTimeField(auto_now_add=True, null=True)),
+ ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+ ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+ ('role', models.CharField(default='peer', max_length=50)),
+ ('termination_id', models.PositiveBigIntegerField(blank=True, null=True)),
+ ('termination_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')),
+ ('outside_ip', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tunnel_termination', to='ipam.ipaddress')),
+ ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+ ('tunnel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='vpn.tunnel')),
+ ],
+ options={
+ 'verbose_name': 'tunnel termination',
+ 'verbose_name_plural': 'tunnel terminations',
+ 'ordering': ('tunnel', 'role', 'pk'),
+ },
+ ),
migrations.AddConstraint(
model_name='tunneltermination',
constraint=models.UniqueConstraint(fields=('termination_type', 'termination_id'), name='vpn_tunneltermination_termination', violation_error_message='An object may be terminated to only one tunnel at a time.'),
diff --git a/netbox/vpn/models/tunnels.py b/netbox/vpn/models/tunnels.py
index f7390d0b4..c1d262d3c 100644
--- a/netbox/vpn/models/tunnels.py
+++ b/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')
diff --git a/netbox/vpn/tables/tunnels.py b/netbox/vpn/tables/tunnels.py
index 9c4ba816d..c10985733 100644
--- a/netbox/vpn/tables/tunnels.py
+++ b/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'),
diff --git a/netbox/vpn/tests/test_api.py b/netbox/vpn/tests/test_api.py
index 2714bd4fc..eb0520c8b 100644
--- a/netbox/vpn/tests/test_api.py
+++ b/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,
},
]
diff --git a/netbox/vpn/tests/test_filtersets.py b/netbox/vpn/tests/test_filtersets.py
index a9eeb1203..2ce3b2dde 100644
--- a/netbox/vpn/tests/test_filtersets.py
+++ b/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)
diff --git a/netbox/vpn/tests/test_views.py b/netbox/vpn/tests/test_views.py
index 4d9080422..ab797d9fd 100644
--- a/netbox/vpn/tests/test_views.py
+++ b/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,
}
diff --git a/netbox/vpn/urls.py b/netbox/vpn/urls.py
index 2bf684313..552f0e185 100644
--- a/netbox/vpn/urls.py
+++ b/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//', 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'),
diff --git a/netbox/vpn/views.py b/netbox/vpn/views.py
index f230e4828..9bf424af9 100644
--- a/netbox/vpn/views.py
+++ b/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
#